mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 23:11:00 +08:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea1e5751f | ||
|
|
f2ebb04fab | ||
|
|
42048764d5 | ||
|
|
e326253fd8 | ||
|
|
edf5c77ea0 | ||
|
|
8870390770 | ||
|
|
c9514e6e19 | ||
|
|
08fa160de4 | ||
|
|
5d4c4922fd | ||
|
|
c5e7c87658 | ||
|
|
f6923b4c47 | ||
|
|
4cf7598a7d | ||
|
|
81b09bef0d | ||
|
|
b21df3de25 | ||
|
|
c49d814182 | ||
|
|
1cb3c72ab7 | ||
|
|
f03372de6a | ||
|
|
d925f40303 | ||
|
|
dc12d895d8 | ||
|
|
0bb14902f2 | ||
|
|
3027a5f6ff | ||
|
|
f320f4760b | ||
|
|
e939933d6f | ||
|
|
06bffe7618 | ||
|
|
7abc087d70 | ||
|
|
eb2ea1981d | ||
|
|
6dc14ec51b | ||
|
|
36f8257a3e | ||
|
|
c55544df46 | ||
|
|
008f2183de | ||
|
|
dd3a3c3bbb | ||
|
|
941eb2e66e | ||
|
|
a98fcb43d6 | ||
|
|
791121ae06 | ||
|
|
0c156e2708 | ||
|
|
017b47fded | ||
|
|
e27ed22c16 | ||
|
|
904d8744ef | ||
|
|
800e0b7360 | ||
|
|
b6a5461a1d | ||
|
|
a4eda61a86 | ||
|
|
a79d0712a4 | ||
|
|
8f782cdc9d | ||
|
|
2f851f3172 |
@@ -1,3 +1,12 @@
|
|||||||
VITE_API = /api
|
# 你的接口地址 (必填)
|
||||||
VITE_API_MUSIC = /music
|
VITE_API = ***
|
||||||
VITE_API_PROXY = http://110.42.251.190:9856
|
# 音乐破解接口地址
|
||||||
|
VITE_API_MUSIC = ***
|
||||||
|
# 代理地址
|
||||||
|
VITE_API_PROXY = ***
|
||||||
|
|
||||||
|
|
||||||
|
# 本地运行代理地址
|
||||||
|
VITE_API_PROXY = /api
|
||||||
|
VITE_API_MUSIC_PROXY = /music
|
||||||
|
VITE_API_PROXY_MUSIC = /music_proxy
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
VITE_API = http://110.42.251.190:9898
|
|
||||||
VITE_API_MUSIC = http://110.42.251.190:4100
|
|
||||||
VITE_API_PROXY = http://110.42.251.190:9856
|
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
"no-console": "off",
|
"no-console": "off",
|
||||||
"no-continue": "off",
|
"no-continue": "off",
|
||||||
"no-restricted-syntax": "off",
|
"no-restricted-syntax": "off",
|
||||||
|
"no-return-assign": "off",
|
||||||
|
"no-unused-expressions": "off",
|
||||||
|
"no-return-await": "off",
|
||||||
"no-plusplus": "off",
|
"no-plusplus": "off",
|
||||||
"no-param-reassign": "off",
|
"no-param-reassign": "off",
|
||||||
"no-shadow": "off",
|
"no-shadow": "off",
|
||||||
@@ -126,7 +129,10 @@
|
|||||||
"prefer-const": "error", // ts provides better types with const
|
"prefer-const": "error", // ts provides better types with const
|
||||||
"prefer-rest-params": "error", // ts provides better types with rest args over arguments
|
"prefer-rest-params": "error", // ts provides better types with rest args over arguments
|
||||||
"prefer-spread": "error", // ts transpiles spread to apply, so no need for manual apply
|
"prefer-spread": "error", // ts transpiles spread to apply, so no need for manual apply
|
||||||
"valid-typeof": "off" // ts(2367)
|
"valid-typeof": "off", // ts(2367)
|
||||||
|
"consistent-return": "off",
|
||||||
|
"no-promise-executor-return": "off",
|
||||||
|
"prefer-promise-reject-errors": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,3 +14,7 @@ package-lock.json
|
|||||||
dist.zip
|
dist.zip
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
.env.*.local
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
69
README.md
69
README.md
@@ -1,4 +1,4 @@
|
|||||||
# 一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
# Alger Music Player
|
||||||
主要功能如下
|
主要功能如下
|
||||||
|
|
||||||
- 音乐推荐
|
- 音乐推荐
|
||||||
@@ -8,6 +8,42 @@
|
|||||||
- 桌面歌词
|
- 桌面歌词
|
||||||
- 歌单 mv 搜索 专辑等功能
|
- 歌单 mv 搜索 专辑等功能
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||||
|
|
||||||
|
## 预览地址
|
||||||
|
[http://mc.alger.fun/](http://mc.alger.fun/)
|
||||||
|
|
||||||
|
## Stargazers over time
|
||||||
|
[](https://starchart.cc/algerkong/AlgerMusicPlayer)
|
||||||
|
|
||||||
|
## 软件截图
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 主要框架
|
||||||
|
- Vue 3 - 渐进式 JavaScript 框架
|
||||||
|
- TypeScript - JavaScript 的超集,添加了类型系统
|
||||||
|
- Electron - 跨平台桌面应用开发框架
|
||||||
|
- Vite - 下一代前端构建工具
|
||||||
|
|
||||||
|
### UI 框架
|
||||||
|
- Naive UI - 基于 Vue 3 的组件库
|
||||||
|
|
||||||
|
### 项目特点
|
||||||
|
- 完整的类型支持(TypeScript)
|
||||||
|
- 模块化设计
|
||||||
|
- 自动化组件和 API 导入
|
||||||
|
- 多平台支持(Web、Desktop、Mobile Web)
|
||||||
|
- 构建优化(代码分割、压缩)
|
||||||
|
|
||||||
## 项目运行
|
## 项目运行
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
@@ -26,16 +62,33 @@
|
|||||||
npm run win ...
|
npm run win ...
|
||||||
# 具体看 package.json
|
# 具体看 package.json
|
||||||
```
|
```
|
||||||
|
#### 注意
|
||||||
|
- 本地运行需要配置 .env.development 文件
|
||||||
|
- 打包需要配置 .env.production 文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.development
|
||||||
|
# 你的接口地址 (必填)
|
||||||
|
VITE_API = ***
|
||||||
|
# 音乐破解接口地址
|
||||||
|
VITE_API_MUSIC = ***
|
||||||
|
# 代理地址
|
||||||
|
VITE_API_PROXY = ***
|
||||||
|
|
||||||
|
|
||||||
|
# 本地运行代理地址
|
||||||
|
VITE_API_PROXY = /api
|
||||||
|
VITE_API_MUSIC_PROXY = /music
|
||||||
|
VITE_API_PROXY_MUSIC = /music_proxy
|
||||||
|
|
||||||
## 软件截图
|
# .env.production
|
||||||

|
# 你的接口地址 (必填)
|
||||||

|
VITE_API = ***
|
||||||

|
# 音乐破解接口地址
|
||||||

|
VITE_API_MUSIC = ***
|
||||||

|
# 代理地址
|
||||||

|
VITE_API_PROXY = ***
|
||||||
|
```
|
||||||
|
|
||||||
## 欢迎提Issues
|
## 欢迎提Issues
|
||||||
|
|
||||||
|
|||||||
11
app.js
11
app.js
@@ -3,6 +3,7 @@ const path = require('path');
|
|||||||
const Store = require('electron-store');
|
const Store = require('electron-store');
|
||||||
const setJson = require('./electron/set.json');
|
const setJson = require('./electron/set.json');
|
||||||
const { loadLyricWindow } = require('./electron/lyric');
|
const { loadLyricWindow } = require('./electron/lyric');
|
||||||
|
const config = require('./electron/config');
|
||||||
|
|
||||||
let mainWin = null;
|
let mainWin = null;
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@@ -11,8 +12,8 @@ function createWindow() {
|
|||||||
height: 780,
|
height: 780,
|
||||||
frame: false,
|
frame: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
nodeIntegration: false,
|
||||||
// contextIsolation: false,
|
contextIsolation: true,
|
||||||
preload: path.join(__dirname, '/electron/preload.js'),
|
preload: path.join(__dirname, '/electron/preload.js'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -20,11 +21,13 @@ function createWindow() {
|
|||||||
win.setMinimumSize(1200, 780);
|
win.setMinimumSize(1200, 780);
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
win.webContents.openDevTools({ mode: 'detach' });
|
win.webContents.openDevTools({ mode: 'detach' });
|
||||||
win.loadURL('http://localhost:4678/');
|
win.loadURL(`http://localhost:${config.development.mainPort}/`);
|
||||||
} else {
|
} else {
|
||||||
win.loadURL(`file://${__dirname}/dist/index.html`);
|
win.loadURL(`file://${__dirname}/dist/index.html`);
|
||||||
}
|
}
|
||||||
const image = nativeImage.createFromPath(path.join(__dirname, 'public/icon.png'));
|
const image = nativeImage
|
||||||
|
.createFromPath(path.join(__dirname, 'public/icon_16x16.png'))
|
||||||
|
.resize({ width: 16, height: 16 });
|
||||||
const tray = new Tray(image);
|
const tray = new Tray(image);
|
||||||
|
|
||||||
// 创建一个上下文菜单
|
// 创建一个上下文菜单
|
||||||
|
|||||||
7
auto-imports.d.ts
vendored
7
auto-imports.d.ts
vendored
@@ -3,6 +3,7 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
// noinspection JSUnusedGlobalSymbols
|
// noinspection JSUnusedGlobalSymbols
|
||||||
// Generated by unplugin-auto-import
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
const EffectScope: typeof import('vue')['EffectScope']
|
const EffectScope: typeof import('vue')['EffectScope']
|
||||||
@@ -35,6 +36,7 @@ declare global {
|
|||||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||||
const onUpdated: typeof import('vue')['onUpdated']
|
const onUpdated: typeof import('vue')['onUpdated']
|
||||||
|
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||||
const provide: typeof import('vue')['provide']
|
const provide: typeof import('vue')['provide']
|
||||||
const reactive: typeof import('vue')['reactive']
|
const reactive: typeof import('vue')['reactive']
|
||||||
const readonly: typeof import('vue')['readonly']
|
const readonly: typeof import('vue')['readonly']
|
||||||
@@ -53,10 +55,13 @@ declare global {
|
|||||||
const useCssModule: typeof import('vue')['useCssModule']
|
const useCssModule: typeof import('vue')['useCssModule']
|
||||||
const useCssVars: typeof import('vue')['useCssVars']
|
const useCssVars: typeof import('vue')['useCssVars']
|
||||||
const useDialog: typeof import('naive-ui')['useDialog']
|
const useDialog: typeof import('naive-ui')['useDialog']
|
||||||
|
const useId: typeof import('vue')['useId']
|
||||||
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
|
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
|
||||||
const useMessage: typeof import('naive-ui')['useMessage']
|
const useMessage: typeof import('naive-ui')['useMessage']
|
||||||
|
const useModel: typeof import('vue')['useModel']
|
||||||
const useNotification: typeof import('naive-ui')['useNotification']
|
const useNotification: typeof import('naive-ui')['useNotification']
|
||||||
const useSlots: typeof import('vue')['useSlots']
|
const useSlots: typeof import('vue')['useSlots']
|
||||||
|
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||||
const watch: typeof import('vue')['watch']
|
const watch: typeof import('vue')['watch']
|
||||||
const watchEffect: typeof import('vue')['watchEffect']
|
const watchEffect: typeof import('vue')['watchEffect']
|
||||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||||
@@ -65,6 +70,6 @@ declare global {
|
|||||||
// for type re-export
|
// for type re-export
|
||||||
declare global {
|
declare global {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
|
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||||
import('vue')
|
import('vue')
|
||||||
}
|
}
|
||||||
|
|||||||
49
build/mac.json
Normal file
49
build/mac.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.alger.music",
|
||||||
|
"productName": "AlgerMusic",
|
||||||
|
"artifactName": "${productName}_${version}_${arch}.${ext}",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist_electron/mac"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"package.json",
|
||||||
|
"app.js",
|
||||||
|
"electron/**/*",
|
||||||
|
"**/*",
|
||||||
|
"public/**/*",
|
||||||
|
"node_modules/**/*"
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"icon": "public/icon.icns",
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "dmg",
|
||||||
|
"arch": ["x64", "arm64"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "public.app-category.music",
|
||||||
|
"darkModeSupport": true
|
||||||
|
},
|
||||||
|
"dmg": {
|
||||||
|
"title": "${productName} ${version}",
|
||||||
|
"icon": "public/icon.icns",
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"x": 410,
|
||||||
|
"y": 150,
|
||||||
|
"type": "link",
|
||||||
|
"path": "/Applications"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 130,
|
||||||
|
"y": 150,
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"window": {
|
||||||
|
"width": 540,
|
||||||
|
"height": 380
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
components.d.ts
vendored
7
components.d.ts
vendored
@@ -1,16 +1,19 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
/* prettier-ignore */
|
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
// Generated by unplugin-vue-components
|
// Generated by unplugin-vue-components
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
InstallAppModal: typeof import('./src/components/common/InstallAppModal.vue')['default']
|
||||||
MPop: typeof import('./src/components/common/MPop.vue')['default']
|
MPop: typeof import('./src/components/common/MPop.vue')['default']
|
||||||
MusicList: typeof import('./src/components/MusicList.vue')['default']
|
MusicList: typeof import('./src/components/MusicList.vue')['default']
|
||||||
|
MvPlayer: typeof import('./src/components/MvPlayer.vue')['default']
|
||||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
|
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||||
@@ -20,12 +23,14 @@ declare module 'vue' {
|
|||||||
NInput: typeof import('naive-ui')['NInput']
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
NLayout: typeof import('naive-ui')['NLayout']
|
NLayout: typeof import('naive-ui')['NLayout']
|
||||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||||
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
NPopover: typeof import('naive-ui')['NPopover']
|
NPopover: typeof import('naive-ui')['NPopover']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NSlider: typeof import('naive-ui')['NSlider']
|
NSlider: typeof import('naive-ui')['NSlider']
|
||||||
NSpin: typeof import('naive-ui')['NSpin']
|
NSpin: typeof import('naive-ui')['NSpin']
|
||||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
|
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||||
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
|
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
|
||||||
PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default']
|
PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default']
|
||||||
PlaylistType: typeof import('./src/components/PlaylistType.vue')['default']
|
PlaylistType: typeof import('./src/components/PlaylistType.vue')['default']
|
||||||
|
|||||||
BIN
docs/img/image-6.png
Normal file
BIN
docs/img/image-6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/img/image-7.png
Normal file
BIN
docs/img/image-7.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/img/image-8.png
Normal file
BIN
docs/img/image-8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
11
electron/config.js
Normal file
11
electron/config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
// 开发环境配置
|
||||||
|
development: {
|
||||||
|
mainPort: 4488,
|
||||||
|
lyricPort: 4488,
|
||||||
|
},
|
||||||
|
// 生产环境配置
|
||||||
|
production: {
|
||||||
|
distPath: '../dist',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { BrowserWindow } = require('electron');
|
const { BrowserWindow } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
let lyricWindow = null;
|
let lyricWindow = null;
|
||||||
|
|
||||||
@@ -10,13 +11,16 @@ const createWin = () => {
|
|||||||
frame: false,
|
frame: false,
|
||||||
show: false,
|
show: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
hasShadow: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
preload: `${__dirname}/preload.js`,
|
preload: `${__dirname}/preload.js`,
|
||||||
contextIsolation: false,
|
webSecurity: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadLyricWindow = (ipcMain) => {
|
const loadLyricWindow = (ipcMain) => {
|
||||||
ipcMain.on('open-lyric', () => {
|
ipcMain.on('open-lyric', () => {
|
||||||
if (lyricWindow) {
|
if (lyricWindow) {
|
||||||
@@ -28,9 +32,9 @@ const loadLyricWindow = (ipcMain) => {
|
|||||||
createWin();
|
createWin();
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
lyricWindow.webContents.openDevTools({ mode: 'detach' });
|
lyricWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
lyricWindow.loadURL('http://localhost:4678/#/lyric');
|
lyricWindow.loadURL(`http://localhost:${config.development.lyricPort}/#/lyric`);
|
||||||
} else {
|
} else {
|
||||||
const distPath = path.resolve(__dirname, '../dist');
|
const distPath = path.resolve(__dirname, config.production.distPath);
|
||||||
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
|
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { contextBridge, ipcRenderer, app } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
// 主进程通信
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
minimize: () => ipcRenderer.send('minimize-window'),
|
minimize: () => ipcRenderer.send('minimize-window'),
|
||||||
maximize: () => ipcRenderer.send('maximize-window'),
|
maximize: () => ipcRenderer.send('maximize-window'),
|
||||||
@@ -11,18 +12,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
|
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const electronHandler = {
|
// 存储相关
|
||||||
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
ipcRenderer: {
|
ipcRenderer: {
|
||||||
setStoreValue: (key, value) => {
|
setStoreValue: (key, value) => ipcRenderer.send('setStore', key, value),
|
||||||
ipcRenderer.send('setStore', key, value);
|
getStoreValue: (key) => ipcRenderer.sendSync('getStore', key),
|
||||||
|
on: (channel, func) => {
|
||||||
|
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||||
},
|
},
|
||||||
|
once: (channel, func) => {
|
||||||
getStoreValue(key) {
|
ipcRenderer.once(channel, (event, ...args) => func(...args));
|
||||||
const resp = ipcRenderer.sendSync('getStore', key);
|
},
|
||||||
return resp;
|
send: (channel, data) => {
|
||||||
|
ipcRenderer.send(channel, data);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
app,
|
});
|
||||||
};
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', electronHandler);
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"isProxy": false,
|
"isProxy": false,
|
||||||
"author": "alger"
|
"author": "alger"
|
||||||
}
|
}
|
||||||
|
|||||||
69
electron/update.js
Normal file
69
electron/update.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const axios = require('axios');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
|
class Updater {
|
||||||
|
constructor(mainWindow) {
|
||||||
|
this.mainWindow = mainWindow;
|
||||||
|
this.updateUrl = 'http://your-server.com/update'; // 更新服务器地址
|
||||||
|
this.version = app.getVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查更新
|
||||||
|
async checkForUpdates() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.updateUrl}/check`, {
|
||||||
|
params: {
|
||||||
|
version: this.version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.hasUpdate) {
|
||||||
|
await this.downloadUpdate(response.data.downloadUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查更新失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载更新
|
||||||
|
async downloadUpdate(downloadUrl) {
|
||||||
|
try {
|
||||||
|
const response = await axios({
|
||||||
|
url: downloadUrl,
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempPath = path.join(app.getPath('temp'), 'update.zip');
|
||||||
|
fs.writeFileSync(tempPath, response.data);
|
||||||
|
|
||||||
|
await this.extractUpdate(tempPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载更新失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压更新
|
||||||
|
async extractUpdate(zipPath) {
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip(zipPath);
|
||||||
|
const targetPath = path.join(__dirname, '../dist'); // 前端文件目录
|
||||||
|
|
||||||
|
// 解压文件
|
||||||
|
zip.extractAllTo(targetPath, true);
|
||||||
|
|
||||||
|
// 删除临时文件
|
||||||
|
fs.unlinkSync(zipPath);
|
||||||
|
|
||||||
|
// 刷新页面
|
||||||
|
this.mainWindow.webContents.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解压更新失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Updater;
|
||||||
61
package.json
61
package.json
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "alger-music",
|
"name": "alger-music",
|
||||||
"version": "1.5.0",
|
"version": "2.1.0",
|
||||||
"description": "这是一个用于音乐播放的应用程序。",
|
"description": "这是一个用于音乐播放的应用程序。",
|
||||||
"author": "Alger <algerkc@qq.com>",
|
"author": "Alger <algerkc@qq.com>",
|
||||||
"main": "app.js",
|
"main": "app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "cross-env NODE_ENV=production vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"start": "set NODE_ENV=development&&electron .",
|
"start": "cross-env NODE_ENV=development electron .",
|
||||||
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
|
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
|
||||||
"b:win:x64": "electron-builder --config ./build/win64.json",
|
"b:win:x64": "cross-env NODE_ENV=production electron-builder --config ./build/win64.json",
|
||||||
"b:win:x86": "electron-builder --config ./build/win32.json",
|
"b:win:x86": "cross-env NODE_ENV=production electron-builder --config ./build/win32.json",
|
||||||
"b:win:arm": "electron-builder --config ./build/winarm64.json"
|
"b:win:arm": "cross-env NODE_ENV=production electron-builder --config ./build/winarm64.json",
|
||||||
|
"b:mac": "cross-env NODE_ENV=production npm run build && electron-builder --config ./build/mac.json",
|
||||||
|
"b:win": "cross-env NODE_ENV=production npm run build && npm run b:win:x64 && npm run b:win:x86 && npm run b:win:arm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-store": "^8.1.0"
|
"electron-store": "^8.1.0"
|
||||||
@@ -21,16 +23,17 @@
|
|||||||
"@tailwindcss/postcss7-compat": "^2.2.4",
|
"@tailwindcss/postcss7-compat": "^2.2.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@vitejs/plugin-vue": "^5.1.3",
|
||||||
"@vue/compiler-sfc": "^3.3.4",
|
"@vue/compiler-sfc": "^3.5.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
"@vue/runtime-core": "^3.3.4",
|
"@vue/runtime-core": "^3.5.0",
|
||||||
"@vueuse/core": "^10.7.1",
|
"@vueuse/core": "^11.0.3",
|
||||||
"@vueuse/electron": "^10.9.0",
|
"@vueuse/electron": "^11.0.3",
|
||||||
"autoprefixer": "^9.8.6",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^0.21.1",
|
"axios": "^1.7.7",
|
||||||
"electron": "^30.0.0",
|
"cross-env": "^7.0.3",
|
||||||
"electron-builder": "^24.13.0",
|
"electron": "^32.2.7",
|
||||||
|
"electron-builder": "^25.0.5",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
@@ -40,22 +43,22 @@
|
|||||||
"eslint-plugin-vue": "^9.21.1",
|
"eslint-plugin-vue": "^9.21.1",
|
||||||
"eslint-plugin-vue-scoped-css": "^2.7.2",
|
"eslint-plugin-vue-scoped-css": "^2.7.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"naive-ui": "^2.38.2",
|
"naive-ui": "^2.39.0",
|
||||||
"postcss": "^7.0.36",
|
"postcss": "^8.4.49",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.3.3",
|
||||||
"remixicon": "^4.2.0",
|
"remixicon": "^4.2.0",
|
||||||
"sass": "^1.35.2",
|
"sass": "^1.78.0",
|
||||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
"tailwindcss": "^3.4.15",
|
||||||
"typescript": "^4.3.2",
|
"typescript": "^5.5.4",
|
||||||
"unplugin-auto-import": "^0.17.2",
|
"unplugin-auto-import": "^0.18.2",
|
||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^0.27.4",
|
||||||
"vfonts": "^0.1.0",
|
"vfonts": "^0.1.0",
|
||||||
"vite": "^4.4.7",
|
"vite": "^5.4.3",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-vue-devtools": "1.0.0-beta.5",
|
"vite-plugin-vue-devtools": "7.4.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.5.0",
|
||||||
"vue-router": "^4.2.4",
|
"vue-router": "^4.4.3",
|
||||||
"vue-tsc": "^0.0.24",
|
"vue-tsc": "^2.1.4",
|
||||||
"vuex": "^4.1.0"
|
"vuex": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
body{
|
body{
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-popover:has(.music-play){
|
||||||
|
border-radius: 1.5rem !important;
|
||||||
}
|
}
|
||||||
BIN
public/icon.icns
Normal file
BIN
public/icon.icns
Normal file
Binary file not shown.
BIN
public/icon_16x16.png
Normal file
BIN
public/icon_16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 626 B |
14
src/App.vue
14
src/App.vue
@@ -1,17 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app" :class="isMobile ? 'mobile' : ''">
|
<div class="app-container" :class="{ mobile: isMobile }">
|
||||||
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
|
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
|
||||||
<n-config-provider :theme="darkTheme">
|
<n-config-provider :theme="darkTheme">
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<keep-alive>
|
<router-view></router-view>
|
||||||
<router-view></router-view>
|
|
||||||
</keep-alive>
|
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
</n-config-provider>
|
</n-config-provider>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script setup lang="ts">
|
||||||
import { darkTheme } from 'naive-ui';
|
import { darkTheme } from 'naive-ui';
|
||||||
|
|
||||||
import store from '@/store';
|
import store from '@/store';
|
||||||
@@ -31,10 +29,8 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
div {
|
.app-container {
|
||||||
box-sizing: border-box;
|
@apply h-full w-full;
|
||||||
}
|
|
||||||
.app {
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const getRecommendMusic = (params: IRecommendMusicParams) => {
|
|||||||
|
|
||||||
// 获取每日推荐
|
// 获取每日推荐
|
||||||
export const getDayRecommend = () => {
|
export const getDayRecommend = () => {
|
||||||
return request.get<IData<IDayRecommend>>('/recommend/songs');
|
return request.get<IData<IData<IDayRecommend>>>('/recommend/songs');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取最新专辑推荐
|
// 获取最新专辑推荐
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { IMvItem, IMvUrlData } from '@/type/mv';
|
|||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
|
|
||||||
// 获取 mv 排行
|
// 获取 mv 排行
|
||||||
export const getTopMv = (limit: number) => {
|
export const getTopMv = (limit = 30, offset = 0) => {
|
||||||
return request.get<IData<Array<IMvItem>>>('/top/mv', {
|
return request({
|
||||||
|
url: '/mv/all',
|
||||||
|
method: 'get',
|
||||||
params: {
|
params: {
|
||||||
limit,
|
limit,
|
||||||
|
offset,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,34 +3,47 @@
|
|||||||
:show="show"
|
:show="show"
|
||||||
:height="isMobile ? '100vh' : '70vh'"
|
:height="isMobile ? '100vh' : '70vh'"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
:drawer-style="{ backgroundColor: 'transparent' }"
|
block-scroll
|
||||||
|
mask-closable
|
||||||
|
:style="{ backgroundColor: 'transparent' }"
|
||||||
|
@mask-click="close"
|
||||||
>
|
>
|
||||||
<div class="music-page">
|
<div class="music-page">
|
||||||
<i class="iconfont icon-icon_error music-close" @click="close"></i>
|
<div class="music-close">
|
||||||
|
<i class="icon iconfont icon-icon_error" @click="close"></i>
|
||||||
|
</div>
|
||||||
<div class="music-title text-el">{{ name }}</div>
|
<div class="music-title text-el">{{ name }}</div>
|
||||||
<!-- 歌单歌曲列表 -->
|
<!-- 歌单歌曲列表 -->
|
||||||
<div class="music-list">
|
<div v-loading="loading" class="music-list">
|
||||||
<n-scrollbar>
|
<n-virtual-list
|
||||||
<div
|
v-if="displayedSongs.length"
|
||||||
v-for="(item, index) in songList"
|
ref="virtualListRef"
|
||||||
:key="item.id"
|
:items="displayedSongs"
|
||||||
:class="setAnimationClass('animate__bounceInUp')"
|
:item-size="60"
|
||||||
:style="setAnimationDelay(index, 50)"
|
:keep-alive="true"
|
||||||
>
|
:min-size="5"
|
||||||
|
:style="{ height: listHeight }"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||||
</div>
|
</template>
|
||||||
<play-bottom />
|
</n-virtual-list>
|
||||||
</n-scrollbar>
|
<div v-else-if="loading" class="loading-more">加载中...</div>
|
||||||
|
<play-bottom />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
// 导入 NVirtualListInst 类型
|
||||||
|
import type { VirtualListInst } from 'naive-ui';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
import { getMusicDetail } from '@/api/music';
|
||||||
import SongItem from '@/components/common/SongItem.vue';
|
import SongItem from '@/components/common/SongItem.vue';
|
||||||
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
import { isMobile } from '@/utils';
|
||||||
|
|
||||||
import PlayBottom from './common/PlayBottom.vue';
|
import PlayBottom from './common/PlayBottom.vue';
|
||||||
|
|
||||||
@@ -40,8 +53,26 @@ const props = defineProps<{
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
songList: any[];
|
songList: any[];
|
||||||
|
loading?: boolean;
|
||||||
|
listInfo?: {
|
||||||
|
trackIds: { id: number }[];
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits(['update:show']);
|
const emit = defineEmits(['update:show', 'update:loading']);
|
||||||
|
|
||||||
|
const page = ref(0);
|
||||||
|
const pageSize = 20;
|
||||||
|
const isLoadingMore = ref(false);
|
||||||
|
const displayedSongs = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 计算总数
|
||||||
|
const total = computed(() => {
|
||||||
|
if (props.listInfo?.trackIds) {
|
||||||
|
return props.listInfo.trackIds.length;
|
||||||
|
}
|
||||||
|
return props.songList.length;
|
||||||
|
});
|
||||||
|
|
||||||
const formatDetail = computed(() => (detail: any) => {
|
const formatDetail = computed(() => (detail: any) => {
|
||||||
const song = {
|
const song = {
|
||||||
@@ -57,12 +88,86 @@ const formatDetail = computed(() => (detail: any) => {
|
|||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
const tracks = props.songList || [];
|
const tracks = props.songList || [];
|
||||||
store.commit('setPlayList', tracks);
|
store.commit(
|
||||||
|
'setPlayList',
|
||||||
|
tracks.map((item) => ({
|
||||||
|
...item,
|
||||||
|
picUrl: item.al.picUrl,
|
||||||
|
song: {
|
||||||
|
artists: item.ar,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
emit('update:show', false);
|
emit('update:show', false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 优化加载更多歌曲的函数
|
||||||
|
const loadMoreSongs = async () => {
|
||||||
|
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return;
|
||||||
|
|
||||||
|
isLoadingMore.value = true;
|
||||||
|
try {
|
||||||
|
if (props.listInfo?.trackIds) {
|
||||||
|
// 如果有 trackIds,需要分批请求歌曲详情
|
||||||
|
const start = page.value * pageSize;
|
||||||
|
const end = Math.min((page.value + 1) * pageSize, total.value);
|
||||||
|
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
|
||||||
|
|
||||||
|
if (trackIds.length > 0) {
|
||||||
|
const { data } = await getMusicDetail(trackIds);
|
||||||
|
displayedSongs.value = [...displayedSongs.value, ...data.songs];
|
||||||
|
page.value++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有 trackIds,直接使用 songList 分页
|
||||||
|
const start = page.value * pageSize;
|
||||||
|
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
|
||||||
|
const newSongs = props.songList.slice(start, end);
|
||||||
|
displayedSongs.value = [...displayedSongs.value, ...newSongs];
|
||||||
|
page.value++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载歌曲失败:', error);
|
||||||
|
} finally {
|
||||||
|
isLoadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加虚拟列表的引用
|
||||||
|
const virtualListRef = ref<VirtualListInst | null>(null);
|
||||||
|
|
||||||
|
// 修改滚动处理函数
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) {
|
||||||
|
loadMoreSongs();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 songList 变化,重置分页状态
|
||||||
|
watch(
|
||||||
|
() => props.songList,
|
||||||
|
(newSongs) => {
|
||||||
|
page.value = 0;
|
||||||
|
displayedSongs.value = newSongs.slice(0, pageSize);
|
||||||
|
if (newSongs.length > pageSize) {
|
||||||
|
page.value = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加计算属性来处理列表高度
|
||||||
|
const listHeight = computed(() => {
|
||||||
|
const baseHeight = '100%'; // 减去标题高度
|
||||||
|
return store.state.isPlay ? `calc(100% - 90px)` : baseHeight; // 112px 是 PlayBottom 的高度
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -71,16 +176,28 @@ const close = () => {
|
|||||||
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
|
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
@apply text-lg font-bold text-white p-4;
|
@apply text-lg font-bold text-white p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-close {
|
&-close {
|
||||||
@apply absolute top-4 right-8 cursor-pointer text-white text-3xl;
|
@apply absolute top-4 right-8 cursor-pointer text-white flex gap-2 items-center;
|
||||||
|
.icon {
|
||||||
|
@apply text-3xl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-list {
|
&-list {
|
||||||
height: calc(100% - 60px);
|
height: calc(100% - 60px);
|
||||||
|
position: relative; // 添加相对定位
|
||||||
|
|
||||||
|
:deep(.n-virtual-list__scroll) {
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,4 +206,26 @@ const close = () => {
|
|||||||
@apply px-4;
|
@apply px-4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
@apply text-center text-white py-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.double-list {
|
||||||
|
.double-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
background-color: #191919;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 PlayBottom 不会影响滚动区域
|
||||||
|
:deep(.bottom) {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
799
src/components/MvPlayer.vue
Normal file
799
src/components/MvPlayer.vue
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
<template>
|
||||||
|
<n-drawer :show="show" height="100vh" placement="bottom" :z-index="999999999">
|
||||||
|
<div class="mv-detail">
|
||||||
|
<div ref="videoContainerRef" class="video-container" :class="{ 'cursor-hidden': !showCursor }">
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
:src="mvUrl"
|
||||||
|
class="video-player"
|
||||||
|
@ended="handleEnded"
|
||||||
|
@timeupdate="handleTimeUpdate"
|
||||||
|
@loadedmetadata="handleLoadedMetadata"
|
||||||
|
@play="isPlaying = true"
|
||||||
|
@pause="isPlaying = false"
|
||||||
|
@click="togglePlay"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<div v-if="autoPlayBlocked" class="play-hint" @click="togglePlay">
|
||||||
|
<n-button quaternary circle size="large">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="48">
|
||||||
|
<i class="ri-play-circle-line"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-controls" :class="{ 'controls-hidden': !showControls }">
|
||||||
|
<div class="progress-bar custom-slider">
|
||||||
|
<n-slider
|
||||||
|
v-model:value="progress"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:tooltip="false"
|
||||||
|
:step="0.1"
|
||||||
|
@update:value="handleProgressChange"
|
||||||
|
>
|
||||||
|
<template #rail>
|
||||||
|
<div class="progress-rail">
|
||||||
|
<div class="progress-buffer" :style="{ width: `${bufferedProgress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-slider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls-main">
|
||||||
|
<div class="left-controls">
|
||||||
|
<n-tooltip v-if="!props.noList" placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle @click="handlePrev">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<n-spin v-if="prevLoading" size="small" />
|
||||||
|
<i v-else class="ri-skip-back-line"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
上一个
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-tooltip placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle @click="togglePlay">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<n-spin v-if="playLoading" size="small" />
|
||||||
|
<i v-else :class="isPlaying ? 'ri-pause-line' : 'ri-play-line'"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ isPlaying ? '暂停' : '播放' }}
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-tooltip v-if="!props.noList" placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle @click="handleNext">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<n-spin v-if="nextLoading" size="small" />
|
||||||
|
<i v-else class="ri-skip-forward-line"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
下一个
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<div class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-controls">
|
||||||
|
<div class="volume-control custom-slider">
|
||||||
|
<n-tooltip placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle @click="toggleMute">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<i :class="volume === 0 ? 'ri-volume-mute-line' : 'ri-volume-up-line'"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ volume === 0 ? '取消静音' : '静音' }}
|
||||||
|
</n-tooltip>
|
||||||
|
<n-slider v-model:value="volume" :min="0" :max="100" :tooltip="false" class="volume-slider" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-tooltip v-if="!props.noList" placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle class="play-mode-btn" @click="togglePlayMode">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ playMode === 'single' ? '单曲循环' : '列表循环' }}
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-tooltip placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle @click="toggleFullscreen">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<i :class="isFullscreen ? 'ri-fullscreen-exit-line' : 'ri-fullscreen-line'"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
||||||
|
</n-tooltip>
|
||||||
|
|
||||||
|
<n-tooltip placement="top">
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary circle @click="handleClose">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon size="24">
|
||||||
|
<i class="ri-close-line"></i>
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
关闭
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加模式切换提示 -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showModeHint" class="mode-hint">
|
||||||
|
<n-icon size="48" class="mode-icon">
|
||||||
|
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
|
||||||
|
</n-icon>
|
||||||
|
<div class="mode-text">
|
||||||
|
{{ playMode === 'single' ? '单曲循环' : '自动播放下一个' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mv-detail-title" :class="{ 'title-hidden': !showControls }">
|
||||||
|
<div class="title">
|
||||||
|
<n-ellipsis>{{ currentMv?.name }}</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
import { getMvUrl } from '@/api/mv';
|
||||||
|
import { IMvItem } from '@/type/mv';
|
||||||
|
|
||||||
|
type PlayMode = 'single' | 'auto';
|
||||||
|
const PLAY_MODE = {
|
||||||
|
Single: 'single' as PlayMode,
|
||||||
|
Auto: 'auto' as PlayMode,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
currentMv?: IMvItem;
|
||||||
|
noList?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
show: false,
|
||||||
|
currentMv: undefined,
|
||||||
|
noList: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:show', value: boolean): void;
|
||||||
|
(e: 'next', loading: (value: boolean) => void): void;
|
||||||
|
(e: 'prev', loading: (value: boolean) => void): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const mvUrl = ref<string>();
|
||||||
|
const playMode = ref<PlayMode>(PLAY_MODE.Auto);
|
||||||
|
|
||||||
|
const videoRef = ref<HTMLVideoElement>();
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const currentTime = ref(0);
|
||||||
|
const duration = ref(0);
|
||||||
|
const progress = ref(0);
|
||||||
|
const bufferedProgress = ref(0);
|
||||||
|
const volume = ref(100);
|
||||||
|
const showControls = ref(true);
|
||||||
|
let controlsTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
if (isPlaying.value) {
|
||||||
|
videoRef.value.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.value.play();
|
||||||
|
}
|
||||||
|
resetCursorTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
if (volume.value === 0) {
|
||||||
|
volume.value = 100;
|
||||||
|
} else {
|
||||||
|
volume.value = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(volume, (newVolume) => {
|
||||||
|
if (videoRef.value) {
|
||||||
|
videoRef.value.volume = newVolume / 100;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleProgressChange = (value: number) => {
|
||||||
|
if (!videoRef.value || !duration.value) return;
|
||||||
|
const newTime = (value / 100) * duration.value;
|
||||||
|
videoRef.value.currentTime = newTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
currentTime.value = videoRef.value.currentTime;
|
||||||
|
if (!isDragging.value) {
|
||||||
|
progress.value = (currentTime.value / duration.value) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRef.value.buffered.length > 0) {
|
||||||
|
bufferedProgress.value = (videoRef.value.buffered.end(0) / duration.value) * 100;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (!videoRef.value) return;
|
||||||
|
duration.value = videoRef.value.duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetControlsTimer = () => {
|
||||||
|
if (controlsTimer) {
|
||||||
|
clearTimeout(controlsTimer);
|
||||||
|
}
|
||||||
|
showControls.value = true;
|
||||||
|
controlsTimer = setTimeout(() => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
showControls.value = false;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
resetControlsTimer();
|
||||||
|
resetCursorTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
if (controlsTimer) {
|
||||||
|
clearTimeout(controlsTimer);
|
||||||
|
}
|
||||||
|
if (cursorTimer) {
|
||||||
|
clearTimeout(cursorTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 currentMv 的变化
|
||||||
|
watch(
|
||||||
|
() => props.currentMv,
|
||||||
|
async (newMv) => {
|
||||||
|
if (newMv) {
|
||||||
|
await loadMvUrl(newMv);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoPlayBlocked = ref(false);
|
||||||
|
|
||||||
|
const playLoading = ref(false);
|
||||||
|
|
||||||
|
const loadMvUrl = async (mv: IMvItem) => {
|
||||||
|
playLoading.value = true;
|
||||||
|
autoPlayBlocked.value = false;
|
||||||
|
try {
|
||||||
|
const res = await getMvUrl(mv.id);
|
||||||
|
mvUrl.value = res.data.data.url;
|
||||||
|
await nextTick();
|
||||||
|
if (videoRef.value) {
|
||||||
|
try {
|
||||||
|
await videoRef.value.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('自动播放失败,可能需要用户交互:', error);
|
||||||
|
autoPlayBlocked.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载MV地址失败:', error);
|
||||||
|
} finally {
|
||||||
|
playLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('update:show', false);
|
||||||
|
if (store.state.playMusicUrl) {
|
||||||
|
store.commit('setIsPlay', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
if (playMode.value === PLAY_MODE.Single) {
|
||||||
|
// 单曲循环模式,重新加载当前MV
|
||||||
|
if (props.currentMv) {
|
||||||
|
loadMvUrl(props.currentMv);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 自动播放模式,触发下一个
|
||||||
|
emit('next', (value: boolean) => {
|
||||||
|
nextLoading.value = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayMode = () => {
|
||||||
|
playMode.value = playMode.value === PLAY_MODE.Auto ? PLAY_MODE.Single : PLAY_MODE.Auto;
|
||||||
|
showModeHint.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showModeHint.value = false;
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDragging = ref(false);
|
||||||
|
|
||||||
|
// 添加全屏相关的状态和方法
|
||||||
|
const videoContainerRef = ref<HTMLElement>();
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
|
||||||
|
// 检查是否支持全屏API
|
||||||
|
const checkFullscreenAPI = () => {
|
||||||
|
const doc = document as any;
|
||||||
|
return {
|
||||||
|
requestFullscreen:
|
||||||
|
videoContainerRef.value?.requestFullscreen ||
|
||||||
|
(videoContainerRef.value as any)?.webkitRequestFullscreen ||
|
||||||
|
(videoContainerRef.value as any)?.mozRequestFullScreen ||
|
||||||
|
(videoContainerRef.value as any)?.msRequestFullscreen,
|
||||||
|
exitFullscreen: doc.exitFullscreen || doc.webkitExitFullscreen || doc.mozCancelFullScreen || doc.msExitFullscreen,
|
||||||
|
fullscreenElement:
|
||||||
|
doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement,
|
||||||
|
fullscreenEnabled:
|
||||||
|
doc.fullscreenEnabled || doc.webkitFullscreenEnabled || doc.mozFullScreenEnabled || doc.msFullscreenEnabled,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换全屏状态
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
const api = checkFullscreenAPI();
|
||||||
|
|
||||||
|
if (!api.fullscreenEnabled) {
|
||||||
|
console.warn('全屏API不可用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!api.fullscreenElement) {
|
||||||
|
await videoContainerRef.value?.requestFullscreen();
|
||||||
|
isFullscreen.value = true;
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
isFullscreen.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换全屏失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听全屏状态变化
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
const api = checkFullscreenAPI();
|
||||||
|
isFullscreen.value = !!api.fullscreenElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在组件挂载时添加全屏变化监听
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在组件卸载时移除监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||||
|
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||||
|
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加键盘快捷键支持
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
toggleFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 添加到现有的 onMounted 中
|
||||||
|
document.addEventListener('keydown', handleKeyPress);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 添加到现有的 onUnmounted 中
|
||||||
|
document.removeEventListener('keydown', handleKeyPress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加提示状态
|
||||||
|
const showModeHint = ref(false);
|
||||||
|
|
||||||
|
// 添加加载状态
|
||||||
|
const prevLoading = ref(false);
|
||||||
|
const nextLoading = ref(false);
|
||||||
|
|
||||||
|
// 添加处理函数
|
||||||
|
const handlePrev = () => {
|
||||||
|
prevLoading.value = true;
|
||||||
|
emit('prev', (value: boolean) => {
|
||||||
|
prevLoading.value = value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
nextLoading.value = true;
|
||||||
|
emit('next', (value: boolean) => {
|
||||||
|
nextLoading.value = value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加鼠标显示状态
|
||||||
|
const showCursor = ref(true);
|
||||||
|
let cursorTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
// 添加重置鼠标计时器的函数
|
||||||
|
const resetCursorTimer = () => {
|
||||||
|
if (cursorTimer) {
|
||||||
|
clearTimeout(cursorTimer);
|
||||||
|
}
|
||||||
|
showCursor.value = true;
|
||||||
|
if (isPlaying.value && !showControls.value) {
|
||||||
|
cursorTimer = setTimeout(() => {
|
||||||
|
showCursor.value = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听播放状态变化
|
||||||
|
watch(isPlaying, (newValue) => {
|
||||||
|
if (!newValue) {
|
||||||
|
showCursor.value = true;
|
||||||
|
if (cursorTimer) {
|
||||||
|
clearTimeout(cursorTimer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetCursorTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加控制栏状态监听
|
||||||
|
watch(showControls, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
showCursor.value = true;
|
||||||
|
if (cursorTimer) {
|
||||||
|
clearTimeout(cursorTimer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetCursorTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.mv-detail {
|
||||||
|
@apply w-full h-full bg-black relative;
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
@apply w-full h-full relative;
|
||||||
|
transition: cursor 0.3s ease;
|
||||||
|
|
||||||
|
&.cursor-hidden {
|
||||||
|
* {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制栏区域保持鼠标可见
|
||||||
|
.custom-controls {
|
||||||
|
* {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-slider {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:fullscreen,
|
||||||
|
&:-webkit-full-screen,
|
||||||
|
&:-moz-full-screen,
|
||||||
|
&:-ms-fullscreen {
|
||||||
|
background: black;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
// 确保全屏时标题栏正确显示
|
||||||
|
.mv-detail-title {
|
||||||
|
@apply px-8 py-6;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@apply text-xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保全屏时控制栏正确显示
|
||||||
|
.custom-controls {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active::after {
|
||||||
|
@apply opacity-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
@apply w-full h-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-controls {
|
||||||
|
@apply absolute bottom-0 left-0 w-full transition-opacity duration-300 ease-in-out;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
padding: 16px 20px;
|
||||||
|
|
||||||
|
&.controls-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
@apply mb-4;
|
||||||
|
|
||||||
|
.progress-rail {
|
||||||
|
@apply relative w-full h-full;
|
||||||
|
|
||||||
|
.progress-buffer {
|
||||||
|
@apply absolute h-full bg-gray-600 rounded-full;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-main {
|
||||||
|
@apply flex justify-between items-center;
|
||||||
|
|
||||||
|
.left-controls,
|
||||||
|
.right-controls {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
@apply text-sm text-white ml-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
@apply text-white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply text-green-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-hint {
|
||||||
|
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 cursor-pointer;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
@apply text-white opacity-80 transform transition-all duration-300;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply opacity-100 scale-110;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv-detail-title {
|
||||||
|
@apply absolute w-full left-0 top-0 px-6 py-4 transition-opacity duration-300 z-50;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
|
||||||
|
&.title-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@apply text-white text-lg font-medium;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-slider {
|
||||||
|
:deep(.n-slider) {
|
||||||
|
--n-rail-height: 4px;
|
||||||
|
--n-rail-color: rgba(255, 255, 255, 0.2);
|
||||||
|
--n-fill-color: var(--primary-color);
|
||||||
|
--n-handle-size: 12px;
|
||||||
|
--n-handle-color: var(--primary-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--n-rail-height: 6px;
|
||||||
|
--n-handle-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-slider-rail {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-slider-handle {
|
||||||
|
@apply transition-opacity duration-200;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .n-slider-handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加模式提示样式
|
||||||
|
.mode-hint {
|
||||||
|
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2;
|
||||||
|
@apply flex flex-col items-center justify-center;
|
||||||
|
@apply bg-black bg-opacity-70 rounded-lg p-4;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
|
.mode-icon {
|
||||||
|
@apply text-white mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-text {
|
||||||
|
@apply text-white text-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加过渡动画
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 tooltip 样式
|
||||||
|
:deep(.n-tooltip) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调左侧控制按钮的样式
|
||||||
|
.left-controls {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
@apply text-sm text-white ml-4; // 增加时间显示的左边距
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可以添加按钮禁用状态的样式
|
||||||
|
:deep(.n-button--disabled) {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加加载动画样式
|
||||||
|
:deep(.n-spin) {
|
||||||
|
.n-spin-body {
|
||||||
|
@apply text-white;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加视频播放器样式
|
||||||
|
.video-player {
|
||||||
|
@apply w-full h-full cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加点击反馈效果
|
||||||
|
.video-container {
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active::after {
|
||||||
|
@apply opacity-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加鼠标隐藏样式
|
||||||
|
.video-container {
|
||||||
|
@apply w-full h-full relative;
|
||||||
|
transition: cursor 0.3s ease;
|
||||||
|
|
||||||
|
&.cursor-hidden {
|
||||||
|
* {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制栏区域保持鼠标可见
|
||||||
|
.custom-controls {
|
||||||
|
* {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-slider {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,10 +5,21 @@
|
|||||||
<div>
|
<div>
|
||||||
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
||||||
<span
|
<span
|
||||||
v-show="isShowAllPlaylistCategory || index <= 19"
|
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
|
||||||
class="play-list-type-item"
|
class="play-list-type-item"
|
||||||
:class="setAnimationClass('animate__bounceIn')"
|
:class="
|
||||||
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
|
setAnimationClass(
|
||||||
|
index <= 19
|
||||||
|
? 'animate__bounceIn'
|
||||||
|
: !isShowAllPlaylistCategory
|
||||||
|
? 'animate__backOutLeft'
|
||||||
|
: 'animate__bounceIn',
|
||||||
|
) +
|
||||||
|
' ' +
|
||||||
|
'type-item-' +
|
||||||
|
index
|
||||||
|
"
|
||||||
|
:style="getAnimationDelay(index)"
|
||||||
@click="handleClickPlaylistType(item.name)"
|
@click="handleClickPlaylistType(item.name)"
|
||||||
>{{ item.name }}</span
|
>{{ item.name }}</span
|
||||||
>
|
>
|
||||||
@@ -17,7 +28,7 @@
|
|||||||
class="play-list-type-showall"
|
class="play-list-type-showall"
|
||||||
:class="setAnimationClass('animate__bounceIn')"
|
:class="setAnimationClass('animate__bounceIn')"
|
||||||
:style="setAnimationDelay(!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30)"
|
:style="setAnimationDelay(!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30)"
|
||||||
@click="isShowAllPlaylistCategory = !isShowAllPlaylistCategory"
|
@click="handleToggleShowAllPlaylistCategory"
|
||||||
>
|
>
|
||||||
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
|
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
|
||||||
</div>
|
</div>
|
||||||
@@ -26,7 +37,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { getPlaylistCategory } from '@/api/home';
|
import { getPlaylistCategory } from '@/api/home';
|
||||||
@@ -36,6 +47,59 @@ import { setAnimationClass, setAnimationDelay } from '@/utils';
|
|||||||
const playlistCategory = ref<IPlayListSort>();
|
const playlistCategory = ref<IPlayListSort>();
|
||||||
// 是否显示全部歌单分类
|
// 是否显示全部歌单分类
|
||||||
const isShowAllPlaylistCategory = ref<boolean>(false);
|
const isShowAllPlaylistCategory = ref<boolean>(false);
|
||||||
|
const DELAY_TIME = 40;
|
||||||
|
const getAnimationDelay = computed(() => {
|
||||||
|
return (index: number) => {
|
||||||
|
if (index <= 19) {
|
||||||
|
return setAnimationDelay(index, DELAY_TIME);
|
||||||
|
}
|
||||||
|
if (!isShowAllPlaylistCategory.value) {
|
||||||
|
const nowIndex = (playlistCategory.value?.sub.length || 0) - index;
|
||||||
|
return setAnimationDelay(nowIndex, DELAY_TIME);
|
||||||
|
}
|
||||||
|
return setAnimationDelay(index - 19, DELAY_TIME);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isShowAllPlaylistCategory, (newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
const elements = playlistCategory.value?.sub.map((item, index) =>
|
||||||
|
document.querySelector(`.type-item-${index}`),
|
||||||
|
) as HTMLElement[];
|
||||||
|
elements
|
||||||
|
.slice(20)
|
||||||
|
.reverse()
|
||||||
|
.forEach((element, index) => {
|
||||||
|
if (element) {
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
(element as HTMLElement).style.position = 'absolute';
|
||||||
|
},
|
||||||
|
index * DELAY_TIME + 400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
isHiding.value = false;
|
||||||
|
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
console.log('element', element);
|
||||||
|
(element as HTMLElement).style.position = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(playlistCategory.value?.sub.length || 0 - 19) * DELAY_TIME,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||||
|
if (element) {
|
||||||
|
(element as HTMLElement).style.position = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 加载歌单分类
|
// 加载歌单分类
|
||||||
const loadPlaylistCategory = async () => {
|
const loadPlaylistCategory = async () => {
|
||||||
@@ -52,6 +116,14 @@ const handleClickPlaylistType = (type: string) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isHiding = ref<boolean>(false);
|
||||||
|
const handleToggleShowAllPlaylistCategory = () => {
|
||||||
|
isShowAllPlaylistCategory.value = !isShowAllPlaylistCategory.value;
|
||||||
|
if (!isShowAllPlaylistCategory.value) {
|
||||||
|
isHiding.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
// 页面初始化
|
// 页面初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPlaylistCategory();
|
loadPlaylistCategory();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="recommend-singer">
|
<div class="recommend-singer">
|
||||||
<div class="recommend-singer-list">
|
<div class="recommend-singer-list">
|
||||||
<div
|
<div
|
||||||
|
v-if="dayRecommendData"
|
||||||
class="recommend-singer-item relative"
|
class="recommend-singer-item relative"
|
||||||
:class="setAnimationClass('animate__backInRight')"
|
:class="setAnimationClass('animate__backInRight')"
|
||||||
:style="setAnimationDelay(0, 100)"
|
:style="setAnimationDelay(0, 100)"
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in hotSingerData?.artists.slice(0, 4)"
|
v-for="(item, index) in hotSingerData?.artists"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="recommend-singer-item relative"
|
class="recommend-singer-item relative"
|
||||||
:class="setAnimationClass('animate__backInRight')"
|
:class="setAnimationClass('animate__backInRight')"
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import { getDayRecommend, getHotSinger } from '@/api/home';
|
import { getDayRecommend, getHotSinger } from '@/api/home';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
@@ -66,34 +68,42 @@ import { IDayRecommend } from '@/type/day_recommend';
|
|||||||
import type { IHotSinger } from '@/type/singer';
|
import type { IHotSinger } from '@/type/singer';
|
||||||
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
|
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
// 歌手信息
|
// 歌手信息
|
||||||
const hotSingerData = ref<IHotSinger>();
|
const hotSingerData = ref<IHotSinger>();
|
||||||
const dayRecommendData = ref<IDayRecommend>();
|
const dayRecommendData = ref<IDayRecommend>();
|
||||||
const showMusic = ref(false);
|
const showMusic = ref(false);
|
||||||
// // 加载推荐歌手
|
|
||||||
// const loadSingerList = async () => {
|
|
||||||
// const { data } = await getHotSinger({ offset: 0, limit: 5 });
|
|
||||||
// hotSingerData.value = data;
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const loadDayRecommend = async () => {
|
|
||||||
// const { data } = await getDayRecommend();
|
|
||||||
// dayRecommendData.value = data.data;
|
|
||||||
// };
|
|
||||||
// 页面初始化
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [{ data: singerData }, { data: dayRecommend }] = await Promise.all([
|
// 第一个请求:获取热门歌手
|
||||||
getHotSinger({ offset: 0, limit: 5 }),
|
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
|
||||||
getDayRecommend(),
|
|
||||||
]);
|
// 第二个请求:获取每日推荐
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { data: dayRecommend },
|
||||||
|
} = await getDayRecommend();
|
||||||
|
console.log('dayRecommend', dayRecommend);
|
||||||
|
// 处理数据
|
||||||
|
if (dayRecommend) {
|
||||||
|
singerData.artists = singerData.artists.slice(0, 4);
|
||||||
|
}
|
||||||
|
dayRecommendData.value = dayRecommend;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error', error);
|
||||||
|
}
|
||||||
|
|
||||||
hotSingerData.value = singerData;
|
hotSingerData.value = singerData;
|
||||||
dayRecommendData.value = dayRecommend.data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('error', error);
|
console.error('error', error);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
const toSearchSinger = (keyword: string) => {
|
const toSearchSinger = (keyword: string) => {
|
||||||
router.push({
|
router.push({
|
||||||
@@ -103,6 +113,13 @@ const toSearchSinger = (keyword: string) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 监听登录状态
|
||||||
|
watchEffect(() => {
|
||||||
|
if (store.state.user) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="recommend-music">
|
<div class="recommend-music">
|
||||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
|
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
|
||||||
<div v-show="recommendMusic?.result" class="recommend-music-list" :class="setAnimationClass('animate__bounceInUp')">
|
<div
|
||||||
|
v-show="recommendMusic?.result"
|
||||||
|
v-loading="loading"
|
||||||
|
class="recommend-music-list"
|
||||||
|
:class="setAnimationClass('animate__bounceInUp')"
|
||||||
|
>
|
||||||
<!-- 推荐音乐列表 -->
|
<!-- 推荐音乐列表 -->
|
||||||
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
|
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
|
||||||
<div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
|
<div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
|
||||||
@@ -24,11 +29,14 @@ import SongItem from './common/SongItem.vue';
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
// 推荐歌曲
|
// 推荐歌曲
|
||||||
const recommendMusic = ref<IRecommendMusic>();
|
const recommendMusic = ref<IRecommendMusic>();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
// 加载推荐歌曲
|
// 加载推荐歌曲
|
||||||
const loadRecommendMusic = async () => {
|
const loadRecommendMusic = async () => {
|
||||||
|
loading.value = true;
|
||||||
const { data } = await getRecommendMusic({ limit: 10 });
|
const { data } = await getRecommendMusic({ limit: 10 });
|
||||||
recommendMusic.value = data;
|
recommendMusic.value = data;
|
||||||
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 页面初始化
|
// 页面初始化
|
||||||
|
|||||||
114
src/components/common/InstallAppModal.vue
Normal file
114
src/components/common/InstallAppModal.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal v-model:show="showModal" preset="dialog" :show-icon="false" :mask-closable="true" class="install-app-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="app-icon">
|
||||||
|
<img src="@/assets/logo.png" alt="App Icon" />
|
||||||
|
</div>
|
||||||
|
<div class="app-info">
|
||||||
|
<h2 class="app-name">Alger Music</h2>
|
||||||
|
<p class="app-desc">在桌面安装应用,获得更好的体验</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
|
||||||
|
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const showModal = ref(false);
|
||||||
|
const isElectron = ref((window as any).electron !== undefined);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
showModal.value = false;
|
||||||
|
localStorage.setItem('installPromptDismissed', 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
// 新页面打开
|
||||||
|
// 识别当前环境是 mac 还是 windows
|
||||||
|
const os = navigator.platform;
|
||||||
|
const isMac = os.includes('Mac');
|
||||||
|
const isWindows = os.includes('Win');
|
||||||
|
const urls = {
|
||||||
|
mac: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.dmg',
|
||||||
|
windows: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.exe',
|
||||||
|
};
|
||||||
|
// 根据操作系统选择下载链接
|
||||||
|
let downloadUrl = '';
|
||||||
|
if (isMac) {
|
||||||
|
downloadUrl = urls.mac;
|
||||||
|
} else if (isWindows) {
|
||||||
|
downloadUrl = urls.windows;
|
||||||
|
}
|
||||||
|
if (downloadUrl) {
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 如果是 electron 环境,不显示安装提示
|
||||||
|
if (isElectron.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已经点击过"暂不安装"
|
||||||
|
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
|
||||||
|
if (isDismissed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showModal.value = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.install-app-modal {
|
||||||
|
:deep(.n-modal) {
|
||||||
|
@apply max-w-sm;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
@apply p-4;
|
||||||
|
.modal-header {
|
||||||
|
@apply flex items-center mb-6;
|
||||||
|
.app-icon {
|
||||||
|
@apply w-16 h-16 mr-4 rounded-2xl overflow-hidden;
|
||||||
|
img {
|
||||||
|
@apply w-full h-full object-cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app-info {
|
||||||
|
@apply flex-1;
|
||||||
|
.app-name {
|
||||||
|
@apply text-xl font-bold mb-1;
|
||||||
|
}
|
||||||
|
.app-desc {
|
||||||
|
@apply text-sm text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
@apply flex gap-3;
|
||||||
|
.n-button {
|
||||||
|
@apply flex-1;
|
||||||
|
}
|
||||||
|
.cancel-btn {
|
||||||
|
@apply bg-gray-800 text-gray-300 border-none;
|
||||||
|
&:hover {
|
||||||
|
@apply bg-gray-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.install-btn {
|
||||||
|
@apply bg-green-600 border-none;
|
||||||
|
&:hover {
|
||||||
|
@apply bg-green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-item" @click="handleClick">
|
<div class="search-item" :class="item.type" @click="handleClick">
|
||||||
<div class="search-item-img">
|
<div class="search-item-img">
|
||||||
<n-image :src="getImgUrl(item.picUrl, '200y200')" lazy preview-disabled />
|
<n-image :src="getImgUrl(item.picUrl, '320y180')" lazy preview-disabled />
|
||||||
|
<div v-if="item.type === 'mv'" class="play">
|
||||||
|
<i class="iconfont icon icon-play"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-item-info">
|
<div class="search-item-info">
|
||||||
<div class="search-item-name">{{ item.name }}</div>
|
<p class="search-item-name">{{ item.name }}</p>
|
||||||
<div class="search-item-artist">{{ item.desc }}</div>
|
<p class="search-item-artist">{{ item.desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MusicList
|
<MusicList
|
||||||
@@ -13,15 +16,18 @@
|
|||||||
v-model:show="showPop"
|
v-model:show="showPop"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:song-list="songList"
|
:song-list="songList"
|
||||||
|
:list-info="listInfo"
|
||||||
/>
|
/>
|
||||||
|
<mv-player v-if="item.type === 'mv'" v-model:show="showPop" :current-mv="getCurrentMv()" no-list />
|
||||||
<PlayVideo v-if="item.type === 'mv'" v-model:show="showPop" :title="item.name" :url="url" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import { getAlbum, getListDetail } from '@/api/list';
|
import { getAlbum, getListDetail } from '@/api/list';
|
||||||
import { getMvUrl } from '@/api/mv';
|
import MvPlayer from '@/components/MvPlayer.vue';
|
||||||
|
import { IMvItem } from '@/type/mv';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -34,13 +40,22 @@ const props = defineProps<{
|
|||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const url = ref('');
|
|
||||||
|
|
||||||
const songList = ref<any[]>([]);
|
const songList = ref<any[]>([]);
|
||||||
|
|
||||||
const showPop = ref(false);
|
const showPop = ref(false);
|
||||||
|
const listInfo = ref<any>(null);
|
||||||
|
|
||||||
|
const getCurrentMv = () => {
|
||||||
|
return {
|
||||||
|
id: props.item.id,
|
||||||
|
name: props.item.name,
|
||||||
|
} as unknown as IMvItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
|
listInfo.value = null;
|
||||||
if (props.item.type === '专辑') {
|
if (props.item.type === '专辑') {
|
||||||
showPop.value = true;
|
showPop.value = true;
|
||||||
const res = await getAlbum(props.item.id);
|
const res = await getAlbum(props.item.id);
|
||||||
@@ -54,11 +69,12 @@ const handleClick = async () => {
|
|||||||
showPop.value = true;
|
showPop.value = true;
|
||||||
const res = await getListDetail(props.item.id);
|
const res = await getListDetail(props.item.id);
|
||||||
songList.value = res.data.playlist.tracks;
|
songList.value = res.data.playlist.tracks;
|
||||||
|
listInfo.value = res.data.playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.item.type === 'mv') {
|
if (props.item.type === 'mv') {
|
||||||
const res = await getMvUrl(props.item.id);
|
store.commit('setIsPlay', false);
|
||||||
url.value = res.data.data.url;
|
store.commit('setPlayMusic', false);
|
||||||
showPop.value = true;
|
showPop.value = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -72,6 +88,7 @@ const handleClick = async () => {
|
|||||||
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
|
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
|
||||||
}
|
}
|
||||||
.search-item-info {
|
.search-item-info {
|
||||||
|
@apply flex-1 overflow-hidden;
|
||||||
&-name {
|
&-name {
|
||||||
@apply text-white text-sm text-center;
|
@apply text-white text-sm text-center;
|
||||||
}
|
}
|
||||||
@@ -80,4 +97,23 @@ const handleClick = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mv {
|
||||||
|
&:hover {
|
||||||
|
.play {
|
||||||
|
@apply opacity-60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search-item-img {
|
||||||
|
width: 160px;
|
||||||
|
height: 90px;
|
||||||
|
@apply rounded-lg relative;
|
||||||
|
}
|
||||||
|
.play {
|
||||||
|
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity;
|
||||||
|
.icon {
|
||||||
|
@apply text-white text-5xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="song-item" :class="{ 'song-mini': mini }">
|
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
|
||||||
<n-image v-if="item.picUrl" :src="getImgUrl(item.picUrl, '40y40')" class="song-item-img" lazy preview-disabled />
|
<n-image
|
||||||
|
v-if="item.picUrl"
|
||||||
|
ref="songImg"
|
||||||
|
:src="getImgUrl(item.picUrl, '40y40')"
|
||||||
|
class="song-item-img"
|
||||||
|
preview-disabled
|
||||||
|
:img-props="{
|
||||||
|
crossorigin: 'anonymous',
|
||||||
|
}"
|
||||||
|
@load="imageLoad"
|
||||||
|
/>
|
||||||
<div class="song-item-content">
|
<div class="song-item-content">
|
||||||
<div class="song-item-content-title">
|
<div v-if="list" class="song-item-content-wrapper">
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||||
</div>
|
<div class="song-item-content-divider">-</div>
|
||||||
<div class="song-item-content-name">
|
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
|
||||||
<span v-for="(artists, artistsindex) in item.song.artists" :key="artistsindex"
|
>{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||||
>{{ artists.name }}{{ artistsindex < item.song.artists.length - 1 ? ' / ' : '' }}</span
|
|
||||||
>
|
>
|
||||||
</n-ellipsis>
|
</n-ellipsis>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="song-item-content-title">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-name">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||||
|
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
|
||||||
|
>{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||||
|
>
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-item-operating">
|
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
|
||||||
<div class="song-item-operating-like">
|
<div class="song-item-operating-like">
|
||||||
<i class="iconfont icon-likefill"></i>
|
<i class="iconfont icon-likefill"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,18 +51,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useTemplateRef } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import type { SongResult } from '@/type/music';
|
import type { SongResult } from '@/type/music';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
|
import { getImageBackground } from '@/utils/linearColor';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
item: SongResult;
|
item: SongResult;
|
||||||
mini?: boolean;
|
mini?: boolean;
|
||||||
|
list?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
mini: false,
|
mini: false,
|
||||||
|
list: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,9 +85,27 @@ const isPlaying = computed(() => {
|
|||||||
|
|
||||||
const emits = defineEmits(['play']);
|
const emits = defineEmits(['play']);
|
||||||
|
|
||||||
|
const songImageRef = useTemplateRef('songImg');
|
||||||
|
|
||||||
|
const imageLoad = async () => {
|
||||||
|
if (!songImageRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { backgroundColor } = await getImageBackground(
|
||||||
|
(songImageRef.value as any).imageRef as unknown as HTMLImageElement,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line vue/no-mutating-props
|
||||||
|
props.item.backgroundColor = backgroundColor;
|
||||||
|
};
|
||||||
|
|
||||||
// 播放音乐 设置音乐详情 打开音乐底栏
|
// 播放音乐 设置音乐详情 打开音乐底栏
|
||||||
const playMusicEvent = async (item: SongResult) => {
|
const playMusicEvent = async (item: SongResult) => {
|
||||||
if (playMusic.value.id === item.id) {
|
if (playMusic.value.id === item.id) {
|
||||||
|
if (play.value) {
|
||||||
|
store.commit('setPlayMusic', false);
|
||||||
|
} else {
|
||||||
|
store.commit('setPlayMusic', true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await store.commit('setPlay', item);
|
await store.commit('setPlay', item);
|
||||||
@@ -145,4 +188,41 @@ const playMusicEvent = async (item: SongResult) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.song-list {
|
||||||
|
@apply p-2 rounded-lg hover:bg-gray-800/50 border border-gray-800/50 mb-2;
|
||||||
|
.song-item-img {
|
||||||
|
@apply w-10 h-10 rounded-lg mr-3;
|
||||||
|
}
|
||||||
|
.song-item-content {
|
||||||
|
@apply flex items-center flex-1;
|
||||||
|
&-wrapper {
|
||||||
|
@apply flex items-center flex-1 text-sm;
|
||||||
|
}
|
||||||
|
&-title {
|
||||||
|
@apply text-white flex-shrink-0 max-w-[45%];
|
||||||
|
}
|
||||||
|
&-divider {
|
||||||
|
@apply mx-2 text-gray-400;
|
||||||
|
}
|
||||||
|
&-name {
|
||||||
|
@apply text-gray-400 flex-1 min-w-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.song-item-operating {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
&-like {
|
||||||
|
@apply cursor-pointer hover:scale-110 transition-transform;
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base text-gray-400 hover:text-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&-play {
|
||||||
|
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
7
src/directive/index.ts
Normal file
7
src/directive/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { vLoading } from './loading/index';
|
||||||
|
|
||||||
|
const directives = {
|
||||||
|
loading: vLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default directives;
|
||||||
40
src/directive/loading/index.ts
Normal file
40
src/directive/loading/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createVNode, render, VNode } from 'vue';
|
||||||
|
|
||||||
|
import Loading from './index.vue';
|
||||||
|
|
||||||
|
const vnode: VNode = createVNode(Loading) as VNode;
|
||||||
|
|
||||||
|
export const vLoading = {
|
||||||
|
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
|
||||||
|
mounted: (el: HTMLElement, binding: any) => {
|
||||||
|
render(vnode, el);
|
||||||
|
},
|
||||||
|
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
|
||||||
|
updated: (el: HTMLElement, binding: any) => {
|
||||||
|
if (binding.value) {
|
||||||
|
vnode?.component?.exposed.show();
|
||||||
|
} else {
|
||||||
|
vnode?.component?.exposed.hide();
|
||||||
|
}
|
||||||
|
// 动态添加删除自定义class: loading-parent
|
||||||
|
formatterClass(el, binding);
|
||||||
|
},
|
||||||
|
// 绑定元素的父组件卸载后调用
|
||||||
|
unmounted: () => {
|
||||||
|
vnode?.component?.exposed.hide();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatterClass(el: HTMLElement, binding: any) {
|
||||||
|
const classStr = el.getAttribute('class');
|
||||||
|
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
|
||||||
|
if (binding.value) {
|
||||||
|
if (tagetClass === -1) {
|
||||||
|
el.setAttribute('class', `${classStr} loading-parent`);
|
||||||
|
}
|
||||||
|
} else if (tagetClass > -1) {
|
||||||
|
const classArray: Array<string> = classStr?.split('') as string[];
|
||||||
|
classArray.splice(tagetClass - 1, tagetClass + 15);
|
||||||
|
el.setAttribute('class', classArray?.join(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/directive/loading/index.vue
Normal file
92
src/directive/loading/index.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!-- -->
|
||||||
|
<template>
|
||||||
|
<div v-if="isShow" class="loading-box">
|
||||||
|
<div class="mask" :style="{ background: maskBackground }"></div>
|
||||||
|
<div class="loading-content-box">
|
||||||
|
<n-spin size="small" />
|
||||||
|
<div :style="{ color: textColor }" class="tip">{{ tip }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NSpin } from 'naive-ui';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
tip: {
|
||||||
|
type: String,
|
||||||
|
default() {
|
||||||
|
return '加载中...';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maskBackground: {
|
||||||
|
type: String,
|
||||||
|
default() {
|
||||||
|
return 'rgba(0, 0, 0, 0.8)';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loadingColor: {
|
||||||
|
type: String,
|
||||||
|
default() {
|
||||||
|
return 'rgba(255, 255, 255, 1)';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
type: String,
|
||||||
|
default() {
|
||||||
|
return 'rgba(255, 255, 255, 1)';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isShow = ref(false);
|
||||||
|
const show = () => {
|
||||||
|
isShow.value = true;
|
||||||
|
};
|
||||||
|
const hide = () => {
|
||||||
|
isShow.value = false;
|
||||||
|
};
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
hide,
|
||||||
|
isShow,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.loading-box {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
|
.n-spin {
|
||||||
|
// color: #ccc;
|
||||||
|
}
|
||||||
|
.mask {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loading-content-box {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.tip {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
src/hooks/IndexDBHook.ts
Normal file
183
src/hooks/IndexDBHook.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
// 创建一个使用 IndexedDB 的组合函数
|
||||||
|
const useIndexedDB = () => {
|
||||||
|
const db = ref<IDBDatabase | null>(null); // 数据库引用
|
||||||
|
|
||||||
|
// 打开数据库并创建表
|
||||||
|
const initDB = (dbName: string, version: number, stores: { name: string; keyPath?: string }[]) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(dbName, version); // 打开数据库请求
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event: any) => {
|
||||||
|
const db = event.target.result; // 获取数据库实例
|
||||||
|
stores.forEach((store) => {
|
||||||
|
if (!db.objectStoreNames.contains(store.name)) {
|
||||||
|
// 确保对象存储(表)创建
|
||||||
|
db.createObjectStore(store.name, {
|
||||||
|
keyPath: store.keyPath || 'id',
|
||||||
|
autoIncrement: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = (event: any) => {
|
||||||
|
db.value = event.target.result; // 保存数据库实例
|
||||||
|
resolve(); // 成功时解析 Promise
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event: any) => {
|
||||||
|
reject(event.target.error); // 失败时拒绝 Promise
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用新增数据
|
||||||
|
const addData = (storeName: string, value: any) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (!db.value) return reject('数据库未初始化'); // 检查数据库是否已初始化
|
||||||
|
const tx = db.value.transaction(storeName, 'readwrite'); // 创建事务
|
||||||
|
const store = tx.objectStore(storeName); // 获取对象存储
|
||||||
|
|
||||||
|
const request = store.add(value); // 添加数据请求
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('成功'); // 成功时输出
|
||||||
|
resolve(); // 解析 Promise
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
console.error('新增失败:', (event.target as IDBRequest).error); // 输出错误
|
||||||
|
reject((event.target as IDBRequest).error); // 拒绝 Promise
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用保存数据(新增或更新)
|
||||||
|
const saveData = (storeName: string, value: any) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (!db.value) return reject('数据库未初始化');
|
||||||
|
const tx = db.value.transaction(storeName, 'readwrite');
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
const request = store.put(value);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('成功');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
reject((event.target as IDBRequest).error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用获取数据
|
||||||
|
const getData = (storeName: string, key: string | number) => {
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
if (!db.value) return reject('数据库未初始化');
|
||||||
|
const tx = db.value.transaction(storeName, 'readonly');
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
const request = store.get(key);
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
if (event.target) {
|
||||||
|
resolve((event.target as IDBRequest).result);
|
||||||
|
} else {
|
||||||
|
reject('事件目标为空');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
reject((event.target as IDBRequest).error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除数据
|
||||||
|
const deleteData = (storeName: string, key: string | number) => {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (!db.value) return reject('数据库未初始化');
|
||||||
|
const tx = db.value.transaction(storeName, 'readwrite');
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
const request = store.delete(key);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('删除成功');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
reject((event.target as IDBRequest).error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查询所有数据
|
||||||
|
const getAllData = (storeName: string) => {
|
||||||
|
return new Promise<any[]>((resolve, reject) => {
|
||||||
|
if (!db.value) return reject('数据库未初始化');
|
||||||
|
const tx = db.value.transaction(storeName, 'readonly');
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = (event) => {
|
||||||
|
if (event.target) {
|
||||||
|
resolve((event.target as IDBRequest).result);
|
||||||
|
} else {
|
||||||
|
reject('事件目标为空');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event) => {
|
||||||
|
reject((event.target as IDBRequest).error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页查询数据
|
||||||
|
const getDataWithPagination = (storeName: string, page: number, pageSize: number) => {
|
||||||
|
return new Promise<any[]>((resolve, reject) => {
|
||||||
|
if (!db.value) return reject('数据库未初始化');
|
||||||
|
const tx = db.value.transaction(storeName, 'readonly');
|
||||||
|
const store = tx.objectStore(storeName);
|
||||||
|
const request = store.openCursor(); // 打开游标请求
|
||||||
|
const results: any[] = []; // 存储结果的数组
|
||||||
|
let index = 0; // 当前索引
|
||||||
|
const skip = (page - 1) * pageSize; // 计算跳过的数量
|
||||||
|
|
||||||
|
request.onsuccess = (event: any) => {
|
||||||
|
const cursor = event.target.result; // 获取游标
|
||||||
|
if (!cursor) {
|
||||||
|
resolve(results); // 如果没有更多数据,解析结果
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= skip && results.length < pageSize) {
|
||||||
|
results.push(cursor.value); // 添加当前游标值到结果
|
||||||
|
}
|
||||||
|
|
||||||
|
index++; // 增加索引
|
||||||
|
cursor.continue(); // 继续游标
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = (event: any) => {
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
initDB,
|
||||||
|
addData,
|
||||||
|
saveData,
|
||||||
|
getData,
|
||||||
|
deleteData,
|
||||||
|
getAllData,
|
||||||
|
getDataWithPagination,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useIndexedDB;
|
||||||
@@ -1,170 +1,269 @@
|
|||||||
import { getMusicLrc } from '@/api/music';
|
import { computed, ref } from 'vue';
|
||||||
import { ILyric } from '@/type/lyric';
|
|
||||||
|
|
||||||
interface ILrcData {
|
import store from '@/store';
|
||||||
text: string;
|
import type { ILyricText, SongResult } from '@/type/music';
|
||||||
trText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const lrcData = ref<ILyric>();
|
const windowData = window as any;
|
||||||
export const newLrcIndex = ref<number>(0);
|
|
||||||
export const lrcArray = ref<Array<ILrcData>>([]);
|
|
||||||
export const lrcTimeArray = ref<Array<Number>>([]);
|
|
||||||
|
|
||||||
export const parseTime = (timeString: string) => {
|
export const isElectron = computed(() => !!windowData.electronAPI);
|
||||||
const [minutes, seconds] = timeString.split(':');
|
|
||||||
return Number(minutes) * 60 + Number(seconds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
|
||||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
export const lrcTimeArray = ref<number[]>([]); // 歌词时间数组
|
||||||
|
export const nowTime = ref(0); // 当前播放时间
|
||||||
|
export const allTime = ref(0); // 总播放时间
|
||||||
|
export const nowIndex = ref(0); // 当前播放歌词
|
||||||
|
export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
|
||||||
|
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
|
||||||
|
export const audio = ref<HTMLAudioElement>(); // 音频对象
|
||||||
|
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
|
||||||
|
|
||||||
function parseLyricLine(lyricLine: string) {
|
watch(
|
||||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
() => store.state.playMusic,
|
||||||
const time = parseTime(timeText);
|
() => {
|
||||||
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
nextTick(() => {
|
||||||
return { time, text };
|
lrcArray.value = playMusic.value.lyric?.lrcArray || [];
|
||||||
}
|
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
|
||||||
|
});
|
||||||
interface ILyricText {
|
},
|
||||||
text: string;
|
{
|
||||||
trText: string;
|
deep: true,
|
||||||
}
|
},
|
||||||
|
);
|
||||||
function parseLyrics(lyricsString: string) {
|
const isPlaying = computed(() => store.state.play as boolean);
|
||||||
const lines = lyricsString.split('\n');
|
|
||||||
const lyrics: Array<ILyricText> = [];
|
|
||||||
const times: number[] = [];
|
|
||||||
lines.forEach((line) => {
|
|
||||||
const { time, text } = parseLyricLine(line);
|
|
||||||
times.push(time);
|
|
||||||
lyrics.push({ text, trText: '' });
|
|
||||||
});
|
|
||||||
return { lyrics, times };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadLrc = async (playMusicId: number): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { data } = await getMusicLrc(playMusicId);
|
|
||||||
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
|
||||||
let tlyric: {
|
|
||||||
[key: string]: string;
|
|
||||||
} = {};
|
|
||||||
if (data.tlyric.lyric) {
|
|
||||||
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
|
||||||
tlyric = tLyrics.reduce((acc: any, cur, index) => {
|
|
||||||
acc[tTimes[index]] = cur.text;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
if (Object.keys(tlyric).length) {
|
|
||||||
lyrics.forEach((item, index) => {
|
|
||||||
item.trText = item.text ? tlyric[times[index].toString()] : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
lrcTimeArray.value = times;
|
|
||||||
lrcArray.value = lyrics;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('err', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 歌词矫正时间Correction time
|
|
||||||
const correctionTime = ref(0.4);
|
|
||||||
|
|
||||||
// 增加矫正时间
|
// 增加矫正时间
|
||||||
export const addCorrectionTime = (time: number) => {
|
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
|
||||||
correctionTime.value += time;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 减少矫正时间
|
// 减少矫正时间
|
||||||
export const reduceCorrectionTime = (time: number) => {
|
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
|
||||||
correctionTime.value -= time;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isCurrentLrc = (index: number, time: number) => {
|
// 获取当前播放歌词
|
||||||
const currentTime = Number(lrcTimeArray.value[index]);
|
export const isCurrentLrc = (index: number, time: number): boolean => {
|
||||||
const nextTime = Number(lrcTimeArray.value[index + 1]);
|
const currentTime = lrcTimeArray.value[index];
|
||||||
|
const nextTime = lrcTimeArray.value[index + 1];
|
||||||
const nowTime = time + correctionTime.value;
|
const nowTime = time + correctionTime.value;
|
||||||
const isTrue = nowTime > currentTime && nowTime < nextTime;
|
const isTrue = nowTime > currentTime && nowTime < nextTime;
|
||||||
if (isTrue) {
|
|
||||||
newLrcIndex.value = index;
|
|
||||||
}
|
|
||||||
return isTrue;
|
return isTrue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const nowTime = ref(0);
|
// 获取当前播放歌词INDEX
|
||||||
export const allTime = ref(0);
|
export const getLrcIndex = (time: number): number => {
|
||||||
export const nowIndex = ref(0);
|
|
||||||
|
|
||||||
export const getLrcIndex = (time: number) => {
|
|
||||||
for (let i = 0; i < lrcTimeArray.value.length; i++) {
|
for (let i = 0; i < lrcTimeArray.value.length; i++) {
|
||||||
if (isCurrentLrc(i, time)) {
|
if (isCurrentLrc(i, time)) {
|
||||||
nowIndex.value = i || nowIndex.value;
|
nowIndex.value = i;
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nowIndex.value;
|
return nowIndex.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 设置当前播放时间
|
// 获取当前播放歌词进度
|
||||||
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
|
const currentLrcTiming = computed(() => {
|
||||||
audio.currentTime = lrcTimeArray.value[index] as number;
|
const start = lrcTimeArray.value[nowIndex.value] || 0;
|
||||||
audio.play();
|
const end = lrcTimeArray.value[nowIndex.value + 1] || start + 1;
|
||||||
|
return { start, end };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取歌词样式
|
||||||
|
export const getLrcStyle = (index: number) => {
|
||||||
|
if (index === nowIndex.value) {
|
||||||
|
return {
|
||||||
|
backgroundImage: `linear-gradient(to right, #ffffff ${currentLrcProgress.value}%, #ffffff8a ${currentLrcProgress.value}%)`,
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
color: 'transparent',
|
||||||
|
transition: 'background-image 0.1s linear',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 计算这个歌词的播放时间
|
watch(nowTime, (newTime) => {
|
||||||
const getLrcTime = (index: number) => {
|
const newIndex = getLrcIndex(newTime);
|
||||||
return Number(lrcTimeArray.value[index]);
|
if (newIndex !== nowIndex.value) {
|
||||||
|
nowIndex.value = newIndex;
|
||||||
|
currentLrcProgress.value = 0; // 重置进度
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放进度
|
||||||
|
export const useLyricProgress = () => {
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
if (!isPlaying.value) return;
|
||||||
|
audio.value = audio.value || (document.querySelector('#MusicAudio') as HTMLAudioElement);
|
||||||
|
if (!audio.value) return;
|
||||||
|
const { start, end } = currentLrcTiming.value;
|
||||||
|
const duration = end - start;
|
||||||
|
const elapsed = audio.value.currentTime - start;
|
||||||
|
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(updateProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startProgressAnimation = () => {
|
||||||
|
if (!animationFrameId && isPlaying.value) {
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopProgressAnimation = () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
animationFrameId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(isPlaying, (newIsPlaying) => {
|
||||||
|
if (newIsPlaying) {
|
||||||
|
startProgressAnimation();
|
||||||
|
} else {
|
||||||
|
stopProgressAnimation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
startProgressAnimation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopProgressAnimation();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLrcProgress,
|
||||||
|
getLrcStyle,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置当前播放时间
|
||||||
|
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
|
||||||
|
audio.currentTime = lrcTimeArray.value[index];
|
||||||
|
audio.play();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前播放的歌词
|
// 获取当前播放的歌词
|
||||||
export const getCurrentLrc = () => {
|
export const getCurrentLrc = () => {
|
||||||
const index = getLrcIndex(nowTime.value);
|
const index = getLrcIndex(nowTime.value);
|
||||||
const currentLrc = lrcArray.value[index];
|
return {
|
||||||
const nextLrc = lrcArray.value[index + 1];
|
currentLrc: lrcArray.value[index],
|
||||||
return { currentLrc, nextLrc };
|
nextLrc: lrcArray.value[index + 1],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取一句歌词播放时间是 几秒到几秒
|
// 获取一句歌词播放时间是 几秒到几秒
|
||||||
export const getLrcTimeRange = (index: number) => {
|
export const getLrcTimeRange = (index: number) => ({
|
||||||
const currentTime = Number(lrcTimeArray.value[index]);
|
currentTime: lrcTimeArray.value[index],
|
||||||
const nextTime = Number(lrcTimeArray.value[index + 1]);
|
nextTime: lrcTimeArray.value[index + 1],
|
||||||
return { currentTime, nextTime };
|
});
|
||||||
|
|
||||||
|
// 监听歌词数组变化,当切换歌曲时重新初始化歌词窗口
|
||||||
|
watch(
|
||||||
|
() => lrcArray.value,
|
||||||
|
(newLrcArray) => {
|
||||||
|
if (newLrcArray.length > 0 && isElectron.value) {
|
||||||
|
// 重新初始化歌词数据
|
||||||
|
initLyricWindow();
|
||||||
|
// 发送当前状态
|
||||||
|
sendLyricToWin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听播放状态变化
|
||||||
|
watch(isPlaying, (newIsPlaying) => {
|
||||||
|
if (isElectron.value) {
|
||||||
|
sendLyricToWin(newIsPlaying);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听时间变化
|
||||||
|
watch(nowTime, (newTime) => {
|
||||||
|
const newIndex = getLrcIndex(newTime);
|
||||||
|
if (newIndex !== nowIndex.value) {
|
||||||
|
nowIndex.value = newIndex;
|
||||||
|
currentLrcProgress.value = 0; // 重置进度
|
||||||
|
// 当索引变化时发送更新
|
||||||
|
if (isElectron.value) {
|
||||||
|
sendLyricToWin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理歌曲结束
|
||||||
|
export const handleEnded = () => {
|
||||||
|
// ... 原有的结束处理逻辑 ...
|
||||||
|
|
||||||
|
// 如果有歌词窗口,发送初始化数据
|
||||||
|
if (isElectron.value) {
|
||||||
|
// 延迟一下等待新歌曲加载完成
|
||||||
|
setTimeout(() => {
|
||||||
|
initLyricWindow();
|
||||||
|
sendLyricToWin();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendLyricToWin = (isPlay: boolean = true) => {
|
// 初始化歌词数据
|
||||||
|
export const initLyricWindow = () => {
|
||||||
|
if (!isElectron.value) return;
|
||||||
try {
|
try {
|
||||||
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
|
|
||||||
let lyricWinData = null;
|
|
||||||
if (lrcArray.value.length > 0) {
|
if (lrcArray.value.length > 0) {
|
||||||
const nowIndex = getLrcIndex(nowTime.value);
|
console.log('Initializing lyric window with data:', {
|
||||||
const { currentLrc, nextLrc } = getCurrentLrc();
|
|
||||||
const { currentTime, nextTime } = getLrcTimeRange(nowIndex);
|
|
||||||
lyricWinData = {
|
|
||||||
currentLrc,
|
|
||||||
nextLrc,
|
|
||||||
currentTime,
|
|
||||||
nextTime,
|
|
||||||
nowIndex,
|
|
||||||
lrcTimeArray: lrcTimeArray.value,
|
|
||||||
lrcArray: lrcArray.value,
|
lrcArray: lrcArray.value,
|
||||||
nowTime: nowTime.value,
|
lrcTimeArray: lrcTimeArray.value,
|
||||||
allTime: allTime.value,
|
allTime: allTime.value,
|
||||||
startCurrentTime: getLrcTime(nowIndex),
|
});
|
||||||
isPlay,
|
|
||||||
};
|
|
||||||
|
|
||||||
const windowData = window as any;
|
const staticData = {
|
||||||
windowData.electronAPI.sendLyric(JSON.stringify(lyricWinData));
|
type: 'init',
|
||||||
|
lrcArray: lrcArray.value,
|
||||||
|
lrcTimeArray: lrcTimeArray.value,
|
||||||
|
allTime: allTime.value,
|
||||||
|
};
|
||||||
|
windowData.electronAPI.sendLyric(JSON.stringify(staticData));
|
||||||
|
} else {
|
||||||
|
console.log('No lyrics available for initialization');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('error', error);
|
console.error('Error initializing lyric window:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送歌词更新数据
|
||||||
|
export const sendLyricToWin = (isPlay: boolean = true) => {
|
||||||
|
if (!isElectron.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lrcArray.value.length > 0) {
|
||||||
|
const nowIndex = getLrcIndex(nowTime.value);
|
||||||
|
const updateData = {
|
||||||
|
type: 'update',
|
||||||
|
nowIndex,
|
||||||
|
nowTime: nowTime.value,
|
||||||
|
startCurrentTime: lrcTimeArray.value[nowIndex],
|
||||||
|
nextTime: lrcTimeArray.value[nowIndex + 1],
|
||||||
|
isPlay,
|
||||||
|
};
|
||||||
|
windowData.electronAPI.sendLyric(JSON.stringify(updateData));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending lyric update:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const openLyric = () => {
|
export const openLyric = () => {
|
||||||
const windowData = window as any;
|
if (!isElectron.value) return;
|
||||||
|
console.log('Opening lyric window');
|
||||||
windowData.electronAPI.openLyric();
|
windowData.electronAPI.openLyric();
|
||||||
sendLyricToWin();
|
|
||||||
|
// 延迟一下初始化,确保窗口已经创建
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Initializing lyric window after delay');
|
||||||
|
initLyricWindow();
|
||||||
|
sendLyricToWin();
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|||||||
174
src/hooks/MusicListHook.ts
Normal file
174
src/hooks/MusicListHook.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||||
|
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||||
|
import type { ILyric, ILyricText, SongResult } from '@/type/music';
|
||||||
|
import { getImgUrl, getMusicProxyUrl } from '@/utils';
|
||||||
|
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||||
|
|
||||||
|
const musicHistory = useMusicHistory();
|
||||||
|
|
||||||
|
// 获取歌曲url
|
||||||
|
const getSongUrl = async (id: number) => {
|
||||||
|
const { data } = await getMusicUrl(id);
|
||||||
|
let url = '';
|
||||||
|
try {
|
||||||
|
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
||||||
|
const res = await getParsingMusicUrl(id);
|
||||||
|
url = res.data.data.url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error', error);
|
||||||
|
}
|
||||||
|
url = url || data.data[0].url;
|
||||||
|
return getMusicProxyUrl(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSongDetail = async (playMusic: SongResult) => {
|
||||||
|
playMusic.playLoading = true;
|
||||||
|
const playMusicUrl = await getSongUrl(playMusic.id);
|
||||||
|
const { backgroundColor, primaryColor } =
|
||||||
|
playMusic.backgroundColor && playMusic.primaryColor
|
||||||
|
? playMusic
|
||||||
|
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
||||||
|
|
||||||
|
playMusic.playLoading = false;
|
||||||
|
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
|
||||||
|
export const useMusicListHook = () => {
|
||||||
|
const handlePlayMusic = async (state: any, playMusic: SongResult) => {
|
||||||
|
const updatedPlayMusic = await getSongDetail(playMusic);
|
||||||
|
state.playMusic = updatedPlayMusic;
|
||||||
|
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
|
||||||
|
state.play = true;
|
||||||
|
// 设置网页标题
|
||||||
|
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
|
||||||
|
loadLrcAsync(state, updatedPlayMusic.id);
|
||||||
|
musicHistory.addMusic(state.playMusic);
|
||||||
|
const playListIndex = state.playList.findIndex((item: SongResult) => item.id === playMusic.id);
|
||||||
|
state.playListIndex = playListIndex;
|
||||||
|
// 请求后续五首歌曲的详情
|
||||||
|
fetchSongs(state, playListIndex + 1, playListIndex + 6);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用于预加载下一首歌曲的 MP3 数据
|
||||||
|
const preloadNextSong = (nextSongUrl: string) => {
|
||||||
|
const audio = new Audio(nextSongUrl);
|
||||||
|
audio.preload = 'auto'; // 设置预加载
|
||||||
|
audio.load(); // 手动加载
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
|
||||||
|
const songs = state.playList.slice(Math.max(0, startIndex), Math.min(endIndex, state.playList.length));
|
||||||
|
|
||||||
|
const detailedSongs = await Promise.all(
|
||||||
|
songs.map(async (song: SongResult) => {
|
||||||
|
// 如果歌曲详情已经存在,就不重复请求
|
||||||
|
if (!song.playMusicUrl) {
|
||||||
|
return await getSongDetail(song);
|
||||||
|
}
|
||||||
|
return song;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// 加载下一首的歌词
|
||||||
|
const nextSong = detailedSongs[0];
|
||||||
|
if (!(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
|
||||||
|
nextSong.lyric = await loadLrc(nextSong.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新播放列表中的歌曲详情
|
||||||
|
detailedSongs.forEach((song, index) => {
|
||||||
|
state.playList[startIndex + index] = song;
|
||||||
|
});
|
||||||
|
preloadNextSong(nextSong.playMusicUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPlay = async (state: any) => {
|
||||||
|
if (state.playList.length === 0) {
|
||||||
|
state.play = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playListIndex = (state.playListIndex + 1) % state.playList.length;
|
||||||
|
await handlePlayMusic(state, state.playList[playListIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevPlay = async (state: any) => {
|
||||||
|
if (state.playList.length === 0) {
|
||||||
|
state.play = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
|
||||||
|
await handlePlayMusic(state, state.playList[playListIndex]);
|
||||||
|
await fetchSongs(state, playListIndex - 5, playListIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTime = (timeString: string): number => {
|
||||||
|
const [minutes, seconds] = timeString.split(':');
|
||||||
|
return Number(minutes) * 60 + Number(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
|
||||||
|
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
||||||
|
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
||||||
|
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
||||||
|
const time = parseTime(timeText);
|
||||||
|
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
||||||
|
return { time, text };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
||||||
|
const lines = lyricsString.split('\n');
|
||||||
|
const lyrics: ILyricText[] = [];
|
||||||
|
const times: number[] = [];
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const { time, text } = parseLyricLine(line);
|
||||||
|
times.push(time);
|
||||||
|
lyrics.push({ text, trText: '' });
|
||||||
|
});
|
||||||
|
return { lyrics, times };
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLrc = async (playMusicId: number): Promise<ILyric> => {
|
||||||
|
try {
|
||||||
|
const { data } = await getMusicLrc(playMusicId);
|
||||||
|
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
||||||
|
const tlyric: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (data.tlyric.lyric) {
|
||||||
|
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
||||||
|
tLyrics.forEach((lyric, index) => {
|
||||||
|
tlyric[tTimes[index].toString()] = lyric.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics.forEach((item, index) => {
|
||||||
|
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
lrcTimeArray: times,
|
||||||
|
lrcArray: lyrics,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading lyrics:', err);
|
||||||
|
return {
|
||||||
|
lrcTimeArray: [],
|
||||||
|
lrcArray: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 异步加载歌词的方法
|
||||||
|
const loadLrcAsync = async (state: any, playMusicId: number) => {
|
||||||
|
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lyrics = await loadLrc(playMusicId);
|
||||||
|
state.playMusic.lyric = lyrics;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlePlayMusic,
|
||||||
|
nextPlay,
|
||||||
|
prevPlay,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-page">
|
<div class="layout-page">
|
||||||
<div class="layout-main">
|
<div class="layout-main" :style="{ background: backgroundColor }">
|
||||||
<title-bar v-if="isElectron" />
|
<title-bar v-if="isElectron" />
|
||||||
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
|
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
|
||||||
<!-- 侧边菜单栏 -->
|
<!-- 侧边菜单栏 -->
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<!-- 搜索栏 -->
|
<!-- 搜索栏 -->
|
||||||
<search-bar />
|
<search-bar />
|
||||||
<!-- 主页面路由 -->
|
<!-- 主页面路由 -->
|
||||||
<div class="main-content bg-black" :native-scrollbar="false">
|
<div class="main-content" :native-scrollbar="false">
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<router-view
|
<router-view
|
||||||
v-slot="{ Component }"
|
v-slot="{ Component }"
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<!-- 底部音乐播放 -->
|
<!-- 底部音乐播放 -->
|
||||||
<play-bar v-if="isPlay" />
|
<play-bar v-if="isPlay" />
|
||||||
</div>
|
</div>
|
||||||
|
<install-app-modal></install-app-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -36,7 +37,9 @@
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
import InstallAppModal from '@/components/common/InstallAppModal.vue';
|
||||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||||
|
import { isElectron } from '@/hooks/MusicHook';
|
||||||
import homeRouter from '@/router/home';
|
import homeRouter from '@/router/home';
|
||||||
import { isMobile } from '@/utils';
|
import { isMobile } from '@/utils';
|
||||||
|
|
||||||
@@ -69,11 +72,18 @@ const audio = {
|
|||||||
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
|
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowData = window as any;
|
const backgroundColor = ref('#000');
|
||||||
const isElectron = computed(() => {
|
// watch(
|
||||||
return !!windowData.electronAPI;
|
// () => store.state.playMusic,
|
||||||
});
|
// () => {
|
||||||
|
// backgroundColor.value = store.state.playMusic.backgroundColor;
|
||||||
|
// console.log('backgroundColor.value', backgroundColor.value);
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// immediate: true,
|
||||||
|
// deep: true,
|
||||||
|
// },
|
||||||
|
// );
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 监听音乐是否播放
|
// 监听音乐是否播放
|
||||||
watch(
|
watch(
|
||||||
@@ -95,14 +105,6 @@ onMounted(() => {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 按下键盘按钮监听
|
|
||||||
document.onkeydown = (e) => {
|
|
||||||
switch (e.code) {
|
|
||||||
case 'Space':
|
|
||||||
return false;
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const audioPlay = () => {
|
const audioPlay = () => {
|
||||||
@@ -130,11 +132,11 @@ const playMusicEvent = async () => {
|
|||||||
.layout-page {
|
.layout-page {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@apply flex justify-center items-center overflow-hidden;
|
@apply flex justify-center items-center overflow-hidden bg-black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-main {
|
.layout-main {
|
||||||
@apply bg-black text-white shadow-xl flex flex-col relative;
|
@apply text-white shadow-xl flex flex-col relative transition-all;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,66 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-drawer :show="musicFull" height="100vh" placement="bottom" :drawer-style="{ backgroundColor: 'transparent' }">
|
<n-drawer
|
||||||
|
:show="musicFull"
|
||||||
|
height="100vh"
|
||||||
|
placement="bottom"
|
||||||
|
:style="{ background: currentBackground || background }"
|
||||||
|
>
|
||||||
<div id="drawer-target">
|
<div id="drawer-target">
|
||||||
<div
|
<div class="drawer-back"></div>
|
||||||
class="drawer-back"
|
|
||||||
:class="{ paused: !isPlaying }"
|
|
||||||
:style="{ backgroundImage: `url(${getImgUrl(playMusic?.picUrl, '300y300')})` }"
|
|
||||||
></div>
|
|
||||||
<div class="music-img">
|
<div class="music-img">
|
||||||
<n-image ref="PicImgRef" :src="getImgUrl(playMusic?.picUrl, '300y300')" class="img" lazy preview-disabled />
|
<n-image ref="PicImgRef" :src="getImgUrl(playMusic?.picUrl, '300y300')" class="img" lazy preview-disabled />
|
||||||
|
<div>
|
||||||
|
<div class="music-content-name">{{ playMusic.name }}</div>
|
||||||
|
<div class="music-content-singer">
|
||||||
|
<span v-for="(item, index) in playMusic.ar || playMusic.song.artists" :key="index">
|
||||||
|
{{ item.name }}{{ index < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="music-content">
|
<div class="music-content">
|
||||||
<div class="music-content-name">{{ playMusic.song.name }}</div>
|
|
||||||
<div class="music-content-singer">
|
|
||||||
<span v-for="(item, index) in playMusic.song.artists" :key="index">
|
|
||||||
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<n-layout
|
<n-layout
|
||||||
ref="lrcSider"
|
ref="lrcSider"
|
||||||
class="music-lrc"
|
class="music-lrc"
|
||||||
style="height: 55vh"
|
style="height: 60vh"
|
||||||
:native-scrollbar="false"
|
:native-scrollbar="false"
|
||||||
@mouseover="mouseOverLayout"
|
@mouseover="mouseOverLayout"
|
||||||
@mouseleave="mouseLeaveLayout"
|
@mouseleave="mouseLeaveLayout"
|
||||||
>
|
>
|
||||||
<template v-for="(item, index) in lrcArray" :key="index">
|
<div ref="lrcContainer">
|
||||||
<div
|
<div
|
||||||
|
v-for="(item, index) in lrcArray"
|
||||||
|
:id="`music-lrc-text-${index}`"
|
||||||
|
:key="index"
|
||||||
class="music-lrc-text"
|
class="music-lrc-text"
|
||||||
:class="{ 'now-text': isCurrentLrc(index, nowTime) }"
|
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||||
@click="setAudioTime(index, audio)"
|
@click="setAudioTime(index, audio)"
|
||||||
>
|
>
|
||||||
<div>{{ item.text }}</div>
|
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||||
<div class="music-lrc-text-tr">{{ item.trText }}</div>
|
<div class="music-lrc-text-tr">{{ item.trText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
<!-- 时间矫正 -->
|
<!-- 时间矫正 -->
|
||||||
<div class="music-content-time">
|
<!-- <div class="music-content-time">
|
||||||
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
|
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
|
||||||
<n-button @click="addCorrectionTime(0.2)">+</n-button>
|
<n-button @click="addCorrectionTime(0.2)">+</n-button>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStore } from 'vuex';
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
import {
|
import { lrcArray, nowIndex, playMusic, setAudioTime, useLyricProgress } from '@/hooks/MusicHook';
|
||||||
addCorrectionTime,
|
|
||||||
isCurrentLrc,
|
|
||||||
lrcArray,
|
|
||||||
newLrcIndex,
|
|
||||||
nowTime,
|
|
||||||
reduceCorrectionTime,
|
|
||||||
setAudioTime,
|
|
||||||
} from '@/hooks/MusicHook';
|
|
||||||
import type { SongResult } from '@/type/music';
|
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
|
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
|
||||||
|
|
||||||
const store = useStore();
|
// 定义 refs
|
||||||
|
const lrcSider = ref<any>(null);
|
||||||
|
const isMouse = ref(false);
|
||||||
|
const lrcContainer = ref<HTMLElement | null>(null);
|
||||||
|
const currentBackground = ref('');
|
||||||
|
const animationFrame = ref<number | null>(null);
|
||||||
|
const isDark = ref(false);
|
||||||
|
|
||||||
|
// 初始化 textColors
|
||||||
|
const textColors = ref(getTextColors());
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
musicFull: {
|
musicFull: {
|
||||||
@@ -71,30 +79,118 @@ const props = defineProps({
|
|||||||
type: HTMLAudioElement,
|
type: HTMLAudioElement,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
background: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 播放的音乐信息
|
|
||||||
const playMusic = computed(() => store.state.playMusic as SongResult);
|
|
||||||
const isPlaying = computed(() => store.state.play as boolean);
|
|
||||||
// 获取歌词滚动dom
|
|
||||||
const lrcSider = ref<any>(null);
|
|
||||||
const isMouse = ref(false);
|
|
||||||
// 歌词滚动方法
|
// 歌词滚动方法
|
||||||
const lrcScroll = () => {
|
const lrcScroll = (behavior = 'smooth') => {
|
||||||
if (props.musicFull && !isMouse.value) {
|
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
|
||||||
const top = newLrcIndex.value * 60 - 225;
|
if (props.musicFull && !isMouse.value && nowEl && lrcContainer.value) {
|
||||||
lrcSider.value.scrollTo({ top, behavior: 'smooth' });
|
const containerRect = lrcContainer.value.getBoundingClientRect();
|
||||||
|
const nowElRect = nowEl.getBoundingClientRect();
|
||||||
|
const relativeTop = nowElRect.top - containerRect.top;
|
||||||
|
const scrollTop = relativeTop - lrcSider.value.$el.getBoundingClientRect().height / 2;
|
||||||
|
lrcSider.value.scrollTo({ top: scrollTop, behavior });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const debouncedLrcScroll = useDebounceFn(lrcScroll, 200);
|
||||||
|
|
||||||
const mouseOverLayout = () => {
|
const mouseOverLayout = () => {
|
||||||
isMouse.value = true;
|
isMouse.value = true;
|
||||||
};
|
};
|
||||||
const mouseLeaveLayout = () => {
|
const mouseLeaveLayout = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isMouse.value = false;
|
isMouse.value = false;
|
||||||
}, 3000);
|
lrcScroll();
|
||||||
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(nowIndex, () => {
|
||||||
|
debouncedLrcScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.musicFull,
|
||||||
|
() => {
|
||||||
|
if (props.musicFull) {
|
||||||
|
nextTick(() => {
|
||||||
|
lrcScroll('instant');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听背景变化
|
||||||
|
watch(
|
||||||
|
() => props.background,
|
||||||
|
(newBg) => {
|
||||||
|
if (!newBg) {
|
||||||
|
textColors.value = getTextColors();
|
||||||
|
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
|
||||||
|
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||||
|
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBackground.value) {
|
||||||
|
if (animationFrame.value) {
|
||||||
|
cancelAnimationFrame(animationFrame.value);
|
||||||
|
}
|
||||||
|
animationFrame.value = animateGradient(currentBackground.value, newBg, (gradient) => {
|
||||||
|
currentBackground.value = gradient;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
currentBackground.value = newBg;
|
||||||
|
}
|
||||||
|
|
||||||
|
textColors.value = getTextColors(newBg);
|
||||||
|
isDark.value = textColors.value.active === '#000000';
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(isDark.value));
|
||||||
|
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
|
||||||
|
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修改 useLyricProgress 的使用方式
|
||||||
|
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
|
||||||
|
|
||||||
|
// 修改 getLrcStyle 函数
|
||||||
|
const getLrcStyle = (index: number) => {
|
||||||
|
const colors = textColors.value || getTextColors;
|
||||||
|
const originalStyle = originalLrcStyle(index);
|
||||||
|
|
||||||
|
if (index === nowIndex.value) {
|
||||||
|
// 当前播放的歌词,使用渐变效果
|
||||||
|
return {
|
||||||
|
...originalStyle,
|
||||||
|
backgroundImage: originalStyle.backgroundImage
|
||||||
|
?.replace(/#ffffff/g, colors.active)
|
||||||
|
.replace(/#ffffff8a/g, `${colors.primary}`),
|
||||||
|
backgroundClip: 'text',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
color: 'transparent',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非当前播放的歌词,使用普通颜色
|
||||||
|
return {
|
||||||
|
color: colors.primary,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件卸载时清理动画
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (animationFrame.value) {
|
||||||
|
cancelAnimationFrame(animationFrame.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
lrcScroll,
|
lrcScroll,
|
||||||
});
|
});
|
||||||
@@ -110,14 +206,12 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.drawer-back {
|
.drawer-back {
|
||||||
@apply absolute bg-cover bg-center opacity-70;
|
@apply absolute bg-cover bg-center;
|
||||||
filter: blur(80px) brightness(80%);
|
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
width: 200%;
|
width: 200%;
|
||||||
height: 200%;
|
height: 200%;
|
||||||
top: -50%;
|
top: -50%;
|
||||||
left: -50%;
|
left: -50%;
|
||||||
animation: round 20s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-back.paused {
|
.drawer-back.paused {
|
||||||
@@ -125,30 +219,27 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
#drawer-target {
|
#drawer-target {
|
||||||
@apply top-0 left-0 absolute w-full h-full overflow-hidden rounded px-24 pt-24 pb-48 flex items-center;
|
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
background-color: rgba(0, 0, 0, 0.747);
|
|
||||||
animation-duration: 300ms;
|
animation-duration: 300ms;
|
||||||
|
|
||||||
.music-img {
|
.music-img {
|
||||||
@apply flex-1 flex justify-center mr-24;
|
@apply flex-1 flex justify-center mr-16 flex-col;
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 360px;
|
||||||
.img {
|
.img {
|
||||||
width: 350px;
|
@apply rounded-xl w-full h-full shadow-2xl;
|
||||||
height: 350px;
|
|
||||||
@apply rounded-xl;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-content {
|
.music-content {
|
||||||
@apply flex flex-col justify-center items-center;
|
@apply flex flex-col justify-center items-center relative;
|
||||||
|
|
||||||
&-name {
|
&-name {
|
||||||
@apply font-bold text-3xl py-2;
|
@apply font-bold text-xl pb-1 pt-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-singer {
|
&-singer {
|
||||||
@apply text-base py-2;
|
@apply text-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,25 +247,38 @@ defineExpose({
|
|||||||
display: none;
|
display: none;
|
||||||
@apply flex justify-center items-center;
|
@apply flex justify-center items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-lrc {
|
.music-lrc {
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 550px;
|
height: 550px;
|
||||||
.now-text {
|
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||||
@apply text-red-500;
|
|
||||||
}
|
|
||||||
&-text {
|
&-text {
|
||||||
@apply text-white text-lg flex flex-col justify-center items-center cursor-pointer font-bold;
|
@apply text-2xl cursor-pointer font-bold px-2 py-4;
|
||||||
height: 60px;
|
transition: all 0.3s ease;
|
||||||
transition: all 0.2s ease-out;
|
background-color: transparent;
|
||||||
|
|
||||||
&:hover {
|
span {
|
||||||
@apply font-bold text-red-500;
|
padding-right: 100px;
|
||||||
|
// display: inline-block;
|
||||||
|
background-clip: text !important;
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-tr {
|
&-tr {
|
||||||
@apply text-sm font-normal;
|
@apply font-normal;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--text-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-text {
|
||||||
|
&:hover {
|
||||||
|
@apply font-bold opacity-100 rounded-xl;
|
||||||
|
background-color: var(--hover-bg-color);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--text-color-active) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,4 +295,8 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.music-drawer {
|
||||||
|
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 展开全屏 -->
|
<!-- 展开全屏 -->
|
||||||
<music-full ref="MusicFullRef" v-model:musicFull="musicFull" :audio="audio.value as HTMLAudioElement" />
|
<music-full
|
||||||
|
ref="MusicFullRef"
|
||||||
|
v-model:music-full="musicFullVisible"
|
||||||
|
:audio="audio.value as HTMLAudioElement"
|
||||||
|
:background="background"
|
||||||
|
/>
|
||||||
<!-- 底部播放栏 -->
|
<!-- 底部播放栏 -->
|
||||||
<div class="music-play-bar" :class="setAnimationClass('animate__bounceInUp')">
|
<div
|
||||||
|
class="music-play-bar"
|
||||||
|
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
|
||||||
|
>
|
||||||
<n-image
|
<n-image
|
||||||
:src="getImgUrl(playMusic?.picUrl, '300y300')"
|
:src="getImgUrl(playMusic?.picUrl, '300y300')"
|
||||||
class="play-bar-img"
|
class="play-bar-img"
|
||||||
@@ -18,9 +26,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="music-content-name">
|
<div class="music-content-name">
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||||
<span v-for="(item, index) in playMusic.song.artists" :key="index">
|
<span v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists" :key="artistsindex"
|
||||||
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
|
>{{ artists.name
|
||||||
</span>
|
}}{{ artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||||
|
>
|
||||||
</n-ellipsis>
|
</n-ellipsis>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,12 +44,12 @@
|
|||||||
<i class="iconfont icon-next"></i>
|
<i class="iconfont icon-next"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="music-time">
|
<div class="music-time custom-slider">
|
||||||
<div class="time">{{ getNowTime }}</div>
|
<div class="time">{{ getNowTime }}</div>
|
||||||
<n-slider v-model:value="timeSlider" :step="0.05" :tooltip="false"></n-slider>
|
<n-slider v-model:value="timeSlider" :step="0.05" :tooltip="false"></n-slider>
|
||||||
<div class="time">{{ getAllTime }}</div>
|
<div class="time">{{ getAllTime }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="audio-volume">
|
<div class="audio-volume custom-slider">
|
||||||
<div>
|
<div>
|
||||||
<i class="iconfont icon-notificationfill"></i>
|
<i class="iconfont icon-notificationfill"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,13 +68,22 @@
|
|||||||
</template>
|
</template>
|
||||||
解析播放
|
解析播放
|
||||||
</n-tooltip> -->
|
</n-tooltip> -->
|
||||||
<n-tooltip class="music-lyric" trigger="hover" :z-index="9999999">
|
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i>
|
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i>
|
||||||
</template>
|
</template>
|
||||||
歌词
|
歌词
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-popover trigger="click" :z-index="99999999" content-class="music-play" raw :show-arrow="false" :delay="200">
|
<n-popover
|
||||||
|
trigger="click"
|
||||||
|
:z-index="99999999"
|
||||||
|
content-class="music-play"
|
||||||
|
raw
|
||||||
|
:show-arrow="false"
|
||||||
|
:delay="200"
|
||||||
|
arrow-wrapper-style=" border-radius:1.5rem"
|
||||||
|
@update-show="scrollToPlayList"
|
||||||
|
>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-tooltip trigger="manual" :z-index="9999999">
|
<n-tooltip trigger="manual" :z-index="9999999">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -76,11 +94,13 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="music-play-list">
|
<div class="music-play-list">
|
||||||
<div class="music-play-list-back"></div>
|
<div class="music-play-list-back"></div>
|
||||||
<n-scrollbar>
|
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
|
||||||
<div class="music-play-list-content">
|
<template #default="{ item }">
|
||||||
<song-item v-for="item in playList" :key="item.id" :item="item" mini></song-item>
|
<div class="music-play-list-content">
|
||||||
</div>
|
<song-item :key="item.id" :item="item" mini></song-item>
|
||||||
</n-scrollbar>
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-virtual-list>
|
||||||
</div>
|
</div>
|
||||||
</n-popover>
|
</n-popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,10 +109,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useTemplateRef } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import SongItem from '@/components/common/SongItem.vue';
|
import SongItem from '@/components/common/SongItem.vue';
|
||||||
import { allTime, loadLrc, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
|
import { allTime, getCurrentLrc, isElectron, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
|
||||||
import type { SongResult } from '@/type/music';
|
import type { SongResult } from '@/type/music';
|
||||||
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
|
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
|
||||||
|
|
||||||
@@ -110,13 +131,14 @@ const playList = computed(() => store.state.playList as SongResult[]);
|
|||||||
const audio = {
|
const audio = {
|
||||||
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
|
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
|
||||||
};
|
};
|
||||||
|
const background = ref('#000');
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => store.state.playMusicUrl,
|
() => store.state.playMusic,
|
||||||
() => {
|
async () => {
|
||||||
loadLrc(playMusic.value.id);
|
background.value = playMusic.value.backgroundColor as string;
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true, deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const audioPlay = () => {
|
const audioPlay = () => {
|
||||||
@@ -188,13 +210,16 @@ function handleGetAudioTime(this: HTMLAudioElement) {
|
|||||||
// 监听音频播放的实时时间事件
|
// 监听音频播放的实时时间事件
|
||||||
const audio = this as HTMLAudioElement;
|
const audio = this as HTMLAudioElement;
|
||||||
// 获取当前播放时间
|
// 获取当前播放时间
|
||||||
nowTime.value = Math.floor(audio.currentTime);
|
nowTime.value = audio.currentTime;
|
||||||
|
getCurrentLrc();
|
||||||
// 获取总时间
|
// 获取总时间
|
||||||
allTime.value = audio.duration;
|
allTime.value = audio.duration;
|
||||||
// 获取音量
|
// 获取音量
|
||||||
audioVolume.value = audio.volume;
|
audioVolume.value = audio.volume;
|
||||||
sendLyricToWin(store.state.isPlay);
|
sendLyricToWin(store.state.isPlay);
|
||||||
MusicFullRef.value?.lrcScroll();
|
// if (musicFullVisible.value) {
|
||||||
|
// MusicFullRef.value?.lrcScroll();
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 播放暂停按钮事件
|
// 播放暂停按钮事件
|
||||||
@@ -206,11 +231,20 @@ const playMusicEvent = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const musicFull = ref(false);
|
const musicFullVisible = ref(false);
|
||||||
|
|
||||||
// 设置musicFull
|
// 设置musicFull
|
||||||
const setMusicFull = () => {
|
const setMusicFull = () => {
|
||||||
musicFull.value = !musicFull.value;
|
musicFullVisible.value = !musicFullVisible.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const palyListRef = useTemplateRef('palyListRef');
|
||||||
|
|
||||||
|
const scrollToPlayList = (val: boolean) => {
|
||||||
|
if (!val) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 });
|
||||||
|
}, 50);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -223,7 +257,7 @@ const setMusicFull = () => {
|
|||||||
@apply h-20 w-full absolute bottom-0 left-0 flex items-center rounded-t-2xl overflow-hidden box-border px-6 py-2;
|
@apply h-20 w-full absolute bottom-0 left-0 flex items-center rounded-t-2xl overflow-hidden box-border px-6 py-2;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
|
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
|
||||||
background-color: rgba(0, 0, 0, 0.747);
|
background-color: #212121;
|
||||||
animation-duration: 0.5s !important;
|
animation-duration: 0.5s !important;
|
||||||
.music-content {
|
.music-content {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
@@ -234,12 +268,16 @@ const setMusicFull = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-name {
|
&-name {
|
||||||
@apply text-xs mt-1;
|
@apply text-xs mt-1 text-gray-100;
|
||||||
@apply text-gray-400;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.play-bar-opcity {
|
||||||
|
@apply bg-transparent;
|
||||||
|
box-shadow: 0 0 20px 5px #0000001d;
|
||||||
|
}
|
||||||
|
|
||||||
.play-bar-img {
|
.play-bar-img {
|
||||||
@apply w-14 h-14 rounded-2xl;
|
@apply w-14 h-14 rounded-2xl;
|
||||||
}
|
}
|
||||||
@@ -262,8 +300,8 @@ const setMusicFull = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-play {
|
&-play {
|
||||||
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
|
|
||||||
background: #383838;
|
background: #383838;
|
||||||
|
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition bg-opacity-40;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,13 +333,14 @@ const setMusicFull = () => {
|
|||||||
.music-play {
|
.music-play {
|
||||||
&-list {
|
&-list {
|
||||||
height: 50vh;
|
height: 50vh;
|
||||||
@apply relative rounded-3xl overflow-hidden;
|
width: 300px;
|
||||||
|
@apply relative rounded-3xl overflow-hidden py-2;
|
||||||
&-back {
|
&-back {
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
@apply absolute top-0 left-0 w-full h-full bg-gray-800 bg-opacity-75;
|
@apply absolute top-0 left-0 w-full h-full bg-gray-800 bg-opacity-75;
|
||||||
}
|
}
|
||||||
&-content {
|
&-content {
|
||||||
padding: 10px;
|
@apply mx-2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,4 +376,37 @@ const setMusicFull = () => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加自定义 slider 样式
|
||||||
|
.custom-slider {
|
||||||
|
:deep(.n-slider) {
|
||||||
|
--n-rail-height: 4px;
|
||||||
|
--n-rail-color: rgba(255, 255, 255, 0.2);
|
||||||
|
--n-fill-color: var(--primary-color);
|
||||||
|
--n-handle-size: 12px;
|
||||||
|
--n-handle-color: var(--primary-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--n-rail-height: 6px;
|
||||||
|
--n-handle-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-slider-rail {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-slider-handle {
|
||||||
|
@apply transition-opacity duration-200;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .n-slider-handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #18a058;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<div class="w-20 px-3 flex justify-between items-center">
|
<div class="w-20 px-3 flex justify-between items-center">
|
||||||
<div>{{ searchTypeOptions.find((item) => item.key === searchType)?.label }}</div>
|
<div>{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}</div>
|
||||||
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
||||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
@@ -35,6 +35,14 @@
|
|||||||
/>
|
/>
|
||||||
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
|
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
|
||||||
</div>
|
</div>
|
||||||
|
<n-tooltip v-if="!isElectron">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="github" @click="toGithub">
|
||||||
|
<i class="ri-github-fill"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>前往 Github</div>
|
||||||
|
</n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -45,6 +53,7 @@ import { useStore } from 'vuex';
|
|||||||
import { getSearchKeyword } from '@/api/home';
|
import { getSearchKeyword } from '@/api/home';
|
||||||
import { getUserDetail, logout } from '@/api/login';
|
import { getUserDetail, logout } from '@/api/login';
|
||||||
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
|
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
|
||||||
|
import { isElectron } from '@/hooks/MusicHook';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -90,7 +99,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 搜索词
|
// 搜索词
|
||||||
const searchValue = ref('');
|
const searchValue = ref('');
|
||||||
const searchType = ref(1);
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
const { value } = searchValue;
|
const { value } = searchValue;
|
||||||
if (value === '') {
|
if (value === '') {
|
||||||
@@ -98,17 +106,21 @@ const search = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (router.currentRoute.value.path === '/search') {
|
||||||
|
store.state.searchValue = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
path: '/search',
|
path: '/search',
|
||||||
query: {
|
query: {
|
||||||
keyword: value,
|
keyword: value,
|
||||||
type: searchType.value,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectSearchType = (key: number) => {
|
const selectSearchType = (key: number) => {
|
||||||
searchType.value = key;
|
store.state.searchType = key;
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchTypeOptions = ref(SEARCH_TYPES);
|
const searchTypeOptions = ref(SEARCH_TYPES);
|
||||||
@@ -132,6 +144,10 @@ const selectItem = async (key: string) => {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toGithub = () => {
|
||||||
|
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -151,4 +167,8 @@ const selectItem = async (key: string) => {
|
|||||||
@apply pl-4;
|
@apply pl-4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.github {
|
||||||
|
@apply cursor-pointer text-gray-100 hover:text-gray-400 text-xl ml-4 rounded-full border border-gray-600 flex justify-center items-center px-2;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import router from '@/router';
|
|||||||
import store from '@/store';
|
import store from '@/store';
|
||||||
|
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import directives from './directive';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
Object.keys(directives).forEach((key: string) => {
|
||||||
|
app.directive(key, directives[key as keyof typeof directives]);
|
||||||
|
});
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(store);
|
app.use(store);
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { createStore } from 'vuex';
|
import { createStore } from 'vuex';
|
||||||
|
|
||||||
import { getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
import { useMusicListHook } from '@/hooks/MusicListHook';
|
||||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
|
||||||
import homeRouter from '@/router/home';
|
import homeRouter from '@/router/home';
|
||||||
import { SongResult } from '@/type/music';
|
import type { SongResult } from '@/type/music';
|
||||||
import { getMusicProxyUrl } from '@/utils';
|
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
menus: any[];
|
menus: any[];
|
||||||
@@ -18,6 +16,8 @@ interface State {
|
|||||||
setData: any;
|
setData: any;
|
||||||
lyric: any;
|
lyric: any;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
searchValue: string;
|
||||||
|
searchType: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: State = {
|
const state: State = {
|
||||||
@@ -32,22 +32,18 @@ const state: State = {
|
|||||||
setData: null,
|
setData: null,
|
||||||
lyric: {},
|
lyric: {},
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
|
searchValue: '',
|
||||||
|
searchType: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowData = window as any;
|
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
|
||||||
|
|
||||||
const musicHistory = useMusicHistory();
|
|
||||||
|
|
||||||
const mutations = {
|
const mutations = {
|
||||||
setMenus(state: State, menus: any[]) {
|
setMenus(state: State, menus: any[]) {
|
||||||
state.menus = menus;
|
state.menus = menus;
|
||||||
},
|
},
|
||||||
async setPlay(state: State, playMusic: SongResult) {
|
async setPlay(state: State, playMusic: SongResult) {
|
||||||
state.playMusic = { ...playMusic, playLoading: true };
|
await handlePlayMusic(state, playMusic);
|
||||||
state.playMusicUrl = await getSongUrl(playMusic.id);
|
|
||||||
state.play = true;
|
|
||||||
musicHistory.addMusic(playMusic);
|
|
||||||
state.playMusic.playLoading = false;
|
|
||||||
},
|
},
|
||||||
setIsPlay(state: State, isPlay: boolean) {
|
setIsPlay(state: State, isPlay: boolean) {
|
||||||
state.isPlay = isPlay;
|
state.isPlay = isPlay;
|
||||||
@@ -60,47 +56,17 @@ const mutations = {
|
|||||||
state.playList = playList;
|
state.playList = playList;
|
||||||
},
|
},
|
||||||
async nextPlay(state: State) {
|
async nextPlay(state: State) {
|
||||||
if (state.playList.length === 0) {
|
await nextPlay(state);
|
||||||
state.play = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.playListIndex = (state.playListIndex + 1) % state.playList.length;
|
|
||||||
await updatePlayMusic(state);
|
|
||||||
},
|
},
|
||||||
async prevPlay(state: State) {
|
async prevPlay(state: State) {
|
||||||
if (state.playList.length === 0) {
|
await prevPlay(state);
|
||||||
state.play = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
|
|
||||||
await updatePlayMusic(state);
|
|
||||||
},
|
},
|
||||||
async setSetData(state: State, setData: any) {
|
async setSetData(state: State, setData: any) {
|
||||||
state.setData = setData;
|
state.setData = setData;
|
||||||
windowData.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
if ((window as any).electron) {
|
||||||
},
|
(window as any).electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
||||||
};
|
|
||||||
|
|
||||||
const getSongUrl = async (id: number) => {
|
|
||||||
const { data } = await getMusicUrl(id);
|
|
||||||
let url = '';
|
|
||||||
try {
|
|
||||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
|
||||||
const res = await getParsingMusicUrl(id);
|
|
||||||
url = res.data.data.url;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.error('error', error);
|
|
||||||
}
|
|
||||||
url = url || data.data[0].url;
|
|
||||||
return getMusicProxyUrl(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePlayMusic = async (state: State) => {
|
|
||||||
state.playMusic = state.playList[state.playListIndex];
|
|
||||||
state.playMusicUrl = await getSongUrl(state.playMusic.id);
|
|
||||||
state.play = true;
|
|
||||||
musicHistory.addMusic(state.playMusic);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const store = createStore({
|
const store = createStore({
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ export interface IRecommendMusic {
|
|||||||
category: number;
|
category: number;
|
||||||
result: SongResult[];
|
result: SongResult[];
|
||||||
}
|
}
|
||||||
|
export interface ILyricText {
|
||||||
|
text: string;
|
||||||
|
trText: string;
|
||||||
|
}
|
||||||
|
export interface ILyric {
|
||||||
|
lrcTimeArray: number[];
|
||||||
|
lrcArray: ILyricText[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SongResult {
|
export interface SongResult {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,9 +24,15 @@ export interface SongResult {
|
|||||||
alg: string;
|
alg: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
playLoading?: boolean;
|
playLoading?: boolean;
|
||||||
|
ar?: Artist[];
|
||||||
|
al?: Album;
|
||||||
|
backgroundColor?: string;
|
||||||
|
primaryColor?: string;
|
||||||
|
playMusicUrl?: string;
|
||||||
|
lyric?: ILyric;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Song {
|
export interface Song {
|
||||||
name: string;
|
name: string;
|
||||||
id: number;
|
id: number;
|
||||||
position: number;
|
position: number;
|
||||||
@@ -64,6 +78,10 @@ interface Song {
|
|||||||
lMusic: BMusic;
|
lMusic: BMusic;
|
||||||
exclusive: boolean;
|
exclusive: boolean;
|
||||||
privilege: Privilege;
|
privilege: Privilege;
|
||||||
|
count?: number;
|
||||||
|
playLoading?: boolean;
|
||||||
|
picUrl?: string;
|
||||||
|
ar: Artist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Privilege {
|
interface Privilege {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const setBackgroundImg = (url: String) => {
|
|||||||
};
|
};
|
||||||
// 设置动画类型
|
// 设置动画类型
|
||||||
export const setAnimationClass = (type: String) => {
|
export const setAnimationClass = (type: String) => {
|
||||||
|
if (store.state.setData && store.state.setData.noAnimate) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
return `animate__animated ${type}`;
|
return `animate__animated ${type}`;
|
||||||
};
|
};
|
||||||
// 设置动画延时
|
// 设置动画延时
|
||||||
@@ -28,16 +31,21 @@ export const secondToMinute = (s: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 格式化数字 千,万, 百万, 千万,亿
|
// 格式化数字 千,万, 百万, 千万,亿
|
||||||
|
const units = [
|
||||||
|
{ value: 1e8, symbol: '亿' },
|
||||||
|
{ value: 1e4, symbol: '万' },
|
||||||
|
];
|
||||||
|
|
||||||
export const formatNumber = (num: string | number) => {
|
export const formatNumber = (num: string | number) => {
|
||||||
num = Number(num);
|
num = Number(num);
|
||||||
if (num < 10000) {
|
for (let i = 0; i < units.length; i++) {
|
||||||
return num;
|
if (num >= units[i].value) {
|
||||||
|
return `${(num / units[i].value).toFixed(1)}${units[i].symbol}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (num < 100000000) {
|
return num.toString();
|
||||||
return `${(num / 10000).toFixed(1)}万`;
|
|
||||||
}
|
|
||||||
return `${(num / 100000000).toFixed(1)}亿`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const windowData = window as any;
|
const windowData = window as any;
|
||||||
export const getIsMc = () => {
|
export const getIsMc = () => {
|
||||||
if (!windowData.electron) {
|
if (!windowData.electron) {
|
||||||
@@ -48,7 +56,7 @@ export const getIsMc = () => {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const ProxyUrl = import.meta.env.VITE_API_PROXY || 'http://110.42.251.190:9856';
|
const ProxyUrl = import.meta.env.VITE_API_PROXY;
|
||||||
|
|
||||||
export const getMusicProxyUrl = (url: string) => {
|
export const getMusicProxyUrl = (url: string) => {
|
||||||
if (!getIsMc()) {
|
if (!getIsMc()) {
|
||||||
@@ -58,11 +66,14 @@ export const getMusicProxyUrl = (url: string) => {
|
|||||||
return `${ProxyUrl}/mc?url=${PUrl}`;
|
return `${ProxyUrl}/mc?url=${PUrl}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImgUrl = computed(() => (url: string | undefined, size: string = '') => {
|
export const getImgUrl = (url: string | undefined, size: string = '') => {
|
||||||
const bdUrl = 'https://image.baidu.com/search/down?url=';
|
const bdUrl = 'https://image.baidu.com/search/down?url=';
|
||||||
const imgUrl = encodeURIComponent(`${url}?param=${size}`);
|
const imgUrl = `${url}?param=${size}`;
|
||||||
return `${bdUrl}${imgUrl}`;
|
if (!getIsMc()) {
|
||||||
});
|
return imgUrl;
|
||||||
|
}
|
||||||
|
return `${bdUrl}${encodeURIComponent(imgUrl)}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const isMobile = computed(() => {
|
export const isMobile = computed(() => {
|
||||||
const flag = navigator.userAgent.match(
|
const flag = navigator.userAgent.match(
|
||||||
|
|||||||
271
src/utils/linearColor.ts
Normal file
271
src/utils/linearColor.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
interface IColor {
|
||||||
|
backgroundColor: string;
|
||||||
|
primaryColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getImageLinearBackground = async (imageSrc: string): Promise<IColor> => {
|
||||||
|
try {
|
||||||
|
const primaryColor = await getImagePrimaryColor(imageSrc);
|
||||||
|
return {
|
||||||
|
backgroundColor: generateGradientBackground(primaryColor),
|
||||||
|
primaryColor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error', error);
|
||||||
|
return {
|
||||||
|
backgroundColor: '',
|
||||||
|
primaryColor: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImageBackground = async (img: HTMLImageElement): Promise<IColor> => {
|
||||||
|
try {
|
||||||
|
const primaryColor = await getImageColor(img);
|
||||||
|
return {
|
||||||
|
backgroundColor: generateGradientBackground(primaryColor),
|
||||||
|
primaryColor,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('error', error);
|
||||||
|
return {
|
||||||
|
backgroundColor: '',
|
||||||
|
primaryColor: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImageColor = (img: HTMLImageElement): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('无法获取canvas上下文'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const color = getAverageColor(imageData.data);
|
||||||
|
resolve(`rgb(${color.join(',')})`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImagePrimaryColor = (imageSrc: string): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.src = imageSrc;
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('无法获取canvas上下文'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const color = getAverageColor(imageData.data);
|
||||||
|
resolve(`rgb(${color.join(',')})`);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => reject(new Error('图片加载失败'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAverageColor = (data: Uint8ClampedArray): number[] => {
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
r += data[i];
|
||||||
|
g += data[i + 1];
|
||||||
|
b += data[i + 2];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGradientBackground = (color: string): string => {
|
||||||
|
const [r, g, b] = color.match(/\d+/g)?.map(Number) || [0, 0, 0];
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
|
||||||
|
// 增加亮度和暗度的差异
|
||||||
|
const lightL = Math.min(l + 0.2, 0.95);
|
||||||
|
const darkL = Math.max(l - 0.3, 0.05);
|
||||||
|
const midL = (lightL + darkL) / 2;
|
||||||
|
|
||||||
|
// 调整饱和度以增强效果
|
||||||
|
const lightS = Math.min(s * 0.8, 1);
|
||||||
|
const darkS = Math.min(s * 1.2, 1);
|
||||||
|
|
||||||
|
const [lightR, lightG, lightB] = hslToRgb(h, lightS, lightL);
|
||||||
|
const [midR, midG, midB] = hslToRgb(h, s, midL);
|
||||||
|
const [darkR, darkG, darkB] = hslToRgb(h, darkS, darkL);
|
||||||
|
|
||||||
|
const lightColor = `rgb(${lightR}, ${lightG}, ${lightB})`;
|
||||||
|
const midColor = `rgb(${midR}, ${midG}, ${midB})`;
|
||||||
|
const darkColor = `rgb(${darkR}, ${darkG}, ${darkB})`;
|
||||||
|
|
||||||
|
// 使用三个颜色点创建更丰富的渐变
|
||||||
|
return `linear-gradient(to bottom, ${lightColor} 0%, ${midColor} 50%, ${darkColor} 100%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions (unchanged)
|
||||||
|
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h = 0;
|
||||||
|
let s;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max === min) {
|
||||||
|
h = s = 0;
|
||||||
|
} else {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [h, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
let r;
|
||||||
|
let g;
|
||||||
|
let b;
|
||||||
|
|
||||||
|
if (s === 0) {
|
||||||
|
r = g = b = l;
|
||||||
|
} else {
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1 / 2) return q;
|
||||||
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1 / 3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1 / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新的接口
|
||||||
|
interface ITextColors {
|
||||||
|
primary: string;
|
||||||
|
active: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新的函数
|
||||||
|
export const calculateBrightness = (r: number, g: number, b: number): number => {
|
||||||
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseGradient = (gradientStr: string) => {
|
||||||
|
const matches = gradientStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
return matches.map((rgb) => {
|
||||||
|
const [r, g, b] = rgb.match(/\d+/g)!.map(Number);
|
||||||
|
return { r, g, b };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const interpolateRGB = (start: number, end: number, progress: number) => {
|
||||||
|
return Math.round(start + (end - start) * progress);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createGradientString = (colors: { r: number; g: number; b: number }[], percentages = [0, 50, 100]) => {
|
||||||
|
return `linear-gradient(to bottom, ${colors
|
||||||
|
.map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`)
|
||||||
|
.join(', ')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextColors = (gradient: string = ''): ITextColors => {
|
||||||
|
const defaultColors = {
|
||||||
|
primary: 'rgba(255, 255, 255, 0.54)',
|
||||||
|
active: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!gradient) return defaultColors;
|
||||||
|
|
||||||
|
const colors = parseGradient(gradient);
|
||||||
|
if (!colors.length) return defaultColors;
|
||||||
|
|
||||||
|
const mainColor = colors[1] || colors[0];
|
||||||
|
const brightness = calculateBrightness(mainColor.r, mainColor.g, mainColor.b);
|
||||||
|
const isDark = brightness > 0.6;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: isDark ? 'rgba(0, 0, 0, 0.54)' : 'rgba(255, 255, 255, 0.54)',
|
||||||
|
active: isDark ? '#000000' : '#ffffff',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHoverBackgroundColor = (isDark: boolean): string => {
|
||||||
|
return isDark ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.08)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animateGradient = (
|
||||||
|
oldGradient: string,
|
||||||
|
newGradient: string,
|
||||||
|
onUpdate: (gradient: string) => void,
|
||||||
|
duration = 1000,
|
||||||
|
) => {
|
||||||
|
const startColors = parseGradient(oldGradient);
|
||||||
|
const endColors = parseGradient(newGradient);
|
||||||
|
if (startColors.length !== endColors.length) return null;
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
const currentColors = startColors.map((startColor, i) => ({
|
||||||
|
r: interpolateRGB(startColor.r, endColors[i].r, progress),
|
||||||
|
g: interpolateRGB(startColor.g, endColors[i].g, progress),
|
||||||
|
b: interpolateRGB(startColor.b, endColors[i].b, progress),
|
||||||
|
}));
|
||||||
|
|
||||||
|
onUpdate(createGradientString(currentColors));
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
return requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
:class="setAnimationClass('animate__bounceIn')"
|
:class="setAnimationClass('animate__bounceIn')"
|
||||||
:style="setAnimationDelay(index, 30)"
|
:style="setAnimationDelay(index, 30)"
|
||||||
>
|
>
|
||||||
<song-item class="history-item-content" :item="item" />
|
<song-item class="history-item-content" :item="item" list @play="handlePlay" />
|
||||||
<div class="history-item-count">
|
<div class="history-item-count min-w-[60px]">
|
||||||
{{ item.count }}
|
{{ item.count }}
|
||||||
</div>
|
</div>
|
||||||
<div class="history-item-delete">
|
<div class="history-item-delete">
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||||
|
|
||||||
@@ -31,7 +33,12 @@ defineOptions({
|
|||||||
name: 'History',
|
name: 'History',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
const { delMusic, musicList } = useMusicHistory();
|
const { delMusic, musicList } = useMusicHistory();
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
store.commit('setPlayList', musicList.value);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -49,7 +56,7 @@ const { delMusic, musicList } = useMusicHistory();
|
|||||||
@apply flex-1;
|
@apply flex-1;
|
||||||
}
|
}
|
||||||
&-count {
|
&-count {
|
||||||
@apply px-4 text-lg;
|
@apply px-4 text-lg text-center;
|
||||||
}
|
}
|
||||||
&-delete {
|
&-delete {
|
||||||
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
|
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
|
||||||
|
|||||||
@@ -11,46 +11,119 @@ defineOptions({
|
|||||||
name: 'List',
|
name: 'List',
|
||||||
});
|
});
|
||||||
|
|
||||||
const recommendList = ref();
|
const ITEMS_PER_ROW = ref(6); // 每行显示的数量
|
||||||
|
const TOTAL_ITEMS = 30; // 每页数量
|
||||||
|
|
||||||
|
// 计算实际需要加载的数量,确保能被每行数量整除
|
||||||
|
const getAdjustedLimit = (perRow: number) => {
|
||||||
|
return Math.ceil(TOTAL_ITEMS / perRow) * perRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendList = ref<any[]>([]);
|
||||||
const showMusic = ref(false);
|
const showMusic = ref(false);
|
||||||
|
const page = ref(0);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const isLoadingMore = ref(false);
|
||||||
|
|
||||||
|
// 计算每个项目在当前页面中的索引
|
||||||
|
const getItemAnimationDelay = (index: number) => {
|
||||||
|
const adjustedLimit = getAdjustedLimit(ITEMS_PER_ROW.value);
|
||||||
|
const currentPageIndex = index % adjustedLimit;
|
||||||
|
return setAnimationDelay(currentPageIndex, 30);
|
||||||
|
};
|
||||||
|
|
||||||
const recommendItem = ref<IRecommendItem | null>();
|
const recommendItem = ref<IRecommendItem | null>();
|
||||||
const listDetail = ref<IListDetail | null>();
|
const listDetail = ref<IListDetail | null>();
|
||||||
|
const listLoading = ref(true);
|
||||||
|
|
||||||
const selectRecommendItem = async (item: IRecommendItem) => {
|
const selectRecommendItem = async (item: IRecommendItem) => {
|
||||||
|
listLoading.value = true;
|
||||||
recommendItem.value = null;
|
recommendItem.value = null;
|
||||||
listDetail.value = null;
|
listDetail.value = null;
|
||||||
showMusic.value = true;
|
showMusic.value = true;
|
||||||
const { data } = await getListDetail(item.id);
|
|
||||||
recommendItem.value = item;
|
recommendItem.value = item;
|
||||||
|
const { data } = await getListDetail(item.id);
|
||||||
listDetail.value = data;
|
listDetail.value = data;
|
||||||
|
listLoading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const listTitle = ref(route.query.type || '歌单列表');
|
const listTitle = ref(route.query.type || '歌单列表');
|
||||||
|
|
||||||
const loadList = async (type: string) => {
|
const loading = ref(false);
|
||||||
const params = {
|
const loadList = async (type: string, isLoadMore = false) => {
|
||||||
cat: type || '',
|
if (!hasMore.value && isLoadMore) return;
|
||||||
limit: 30,
|
if (isLoadMore) {
|
||||||
offset: 0,
|
isLoadingMore.value = true;
|
||||||
};
|
} else {
|
||||||
const { data } = await getListByCat(params);
|
loading.value = true;
|
||||||
recommendList.value = data.playlists;
|
page.value = 0;
|
||||||
|
recommendList.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adjustedLimit = getAdjustedLimit(ITEMS_PER_ROW.value);
|
||||||
|
const params = {
|
||||||
|
cat: type || '',
|
||||||
|
limit: adjustedLimit,
|
||||||
|
offset: page.value * adjustedLimit,
|
||||||
|
};
|
||||||
|
const { data } = await getListByCat(params);
|
||||||
|
if (isLoadMore) {
|
||||||
|
recommendList.value.push(...data.playlists);
|
||||||
|
} else {
|
||||||
|
recommendList.value = data.playlists;
|
||||||
|
}
|
||||||
|
hasMore.value = data.more;
|
||||||
|
page.value++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载歌单列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
isLoadingMore.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (route.query.type) {
|
// 监听滚动事件
|
||||||
loadList(route.query.type as string);
|
const handleScroll = (e: any) => {
|
||||||
} else {
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
getRecommendList().then((res: { data: { result: any } }) => {
|
// 距离底部100px时加载更多
|
||||||
recommendList.value = res.data.result;
|
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||||
});
|
loadList(route.query.type as string, true);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听窗口大小变化,调整每行显示数量
|
||||||
|
const updateItemsPerRow = () => {
|
||||||
|
const width = window.innerWidth;
|
||||||
|
if (width > 1800) ITEMS_PER_ROW.value = 8;
|
||||||
|
else if (width > 1200) ITEMS_PER_ROW.value = 8;
|
||||||
|
else if (width > 768) ITEMS_PER_ROW.value = 6;
|
||||||
|
else ITEMS_PER_ROW.value = 5;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateItemsPerRow();
|
||||||
|
window.addEventListener('resize', updateItemsPerRow);
|
||||||
|
if (route.query.type) {
|
||||||
|
loadList(route.query.type as string);
|
||||||
|
} else {
|
||||||
|
getRecommendList(getAdjustedLimit(ITEMS_PER_ROW.value)).then((res: { data: { result: any } }) => {
|
||||||
|
recommendList.value = res.data.result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateItemsPerRow);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
async (newParams) => {
|
async (newParams) => {
|
||||||
if (newParams.type) {
|
if (newParams.type) {
|
||||||
recommendList.value = null;
|
recommendList.value = [];
|
||||||
|
listTitle.value = newParams.type || '歌单列表';
|
||||||
loadList(newParams.type as string);
|
loadList(newParams.type as string);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -61,14 +134,14 @@ watch(
|
|||||||
<div class="list-page">
|
<div class="list-page">
|
||||||
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
|
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
|
||||||
<!-- 歌单列表 -->
|
<!-- 歌单列表 -->
|
||||||
<n-scrollbar class="recommend" :size="100" @click="showMusic = false">
|
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
|
||||||
<div v-if="recommendList" class="recommend-list">
|
<div v-loading="loading" class="recommend-list">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in recommendList"
|
v-for="(item, index) in recommendList"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="recommend-item"
|
class="recommend-item"
|
||||||
:class="setAnimationClass('animate__bounceIn')"
|
:class="setAnimationClass('animate__bounceIn')"
|
||||||
:style="setAnimationDelay(index, 30)"
|
:style="getItemAnimationDelay(index)"
|
||||||
@click.stop="selectRecommendItem(item)"
|
@click.stop="selectRecommendItem(item)"
|
||||||
>
|
>
|
||||||
<div class="recommend-item-img">
|
<div class="recommend-item-img">
|
||||||
@@ -88,19 +161,26 @@ watch(
|
|||||||
<div class="recommend-item-title">{{ item.name }}</div>
|
<div class="recommend-item-title">{{ item.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoadingMore" class="loading-more">
|
||||||
|
<n-spin size="small" />
|
||||||
|
<span class="ml-2">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hasMore && recommendList.length > 0" class="no-more">没有更多了</div>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
<music-list
|
<music-list
|
||||||
v-if="listDetail?.playlist"
|
|
||||||
v-model:show="showMusic"
|
v-model:show="showMusic"
|
||||||
:name="listDetail?.playlist.name"
|
v-model:loading="listLoading"
|
||||||
:song-list="listDetail?.playlist.tracks"
|
:name="recommendItem?.name || ''"
|
||||||
|
:song-list="listDetail?.playlist.tracks || []"
|
||||||
|
:list-info="listDetail?.playlist"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.list-page {
|
.list-page {
|
||||||
@apply relative h-full w-full px-4;
|
@apply relative h-full w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recommend {
|
.recommend {
|
||||||
@@ -110,18 +190,22 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-list {
|
&-list {
|
||||||
@apply grid gap-6 pb-28;
|
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(13%, 1fr));
|
grid-template-columns: repeat(v-bind(ITEMS_PER_ROW), minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
&-item {
|
&-item {
|
||||||
|
@apply flex flex-col;
|
||||||
&-img {
|
&-img {
|
||||||
@apply rounded-xl overflow-hidden relative;
|
@apply rounded-xl overflow-hidden relative w-full;
|
||||||
|
&-img {
|
||||||
|
@apply block w-full h-full;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
@apply absolute top-0 left-0 w-full h-full object-cover rounded-xl;
|
||||||
|
}
|
||||||
&:hover img {
|
&:hover img {
|
||||||
@apply hover:scale-110 transition-all duration-300 ease-in-out;
|
@apply hover:scale-110 transition-all duration-300 ease-in-out;
|
||||||
}
|
}
|
||||||
&-img {
|
|
||||||
@apply h-full w-full rounded-xl overflow-hidden;
|
|
||||||
}
|
|
||||||
.top {
|
.top {
|
||||||
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
||||||
background-color: #00000088;
|
background-color: #00000088;
|
||||||
@@ -139,10 +223,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play-count {
|
.play-count {
|
||||||
position: absolute;
|
@apply absolute top-2 left-2 text-sm;
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,9 +233,11 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile {
|
.loading-more {
|
||||||
.recommend-list {
|
@apply flex items-center justify-center py-4 text-sm text-gray-400;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(25%, 1fr));
|
}
|
||||||
}
|
|
||||||
|
.no-more {
|
||||||
|
@apply text-center py-4 text-sm text-gray-500;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ const timerIsQr = (key: string) => {
|
|||||||
return timer;
|
return timer;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 离开页面时
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timerRef.value) {
|
||||||
|
clearInterval(timerRef.value);
|
||||||
|
timerRef.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 是否扫码登陆
|
// 是否扫码登陆
|
||||||
const isQr = ref(!isMobile.value);
|
const isQr = ref(!isMobile.value);
|
||||||
const chooseQr = () => {
|
const chooseQr = () => {
|
||||||
|
|||||||
@@ -1,76 +1,107 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="lyric-window" :class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]">
|
<div
|
||||||
<div class="drag-bar"></div>
|
class="lyric-window"
|
||||||
<div class="lyric-bar" :class="{ 'lyric-bar-hover': isDrag }">
|
:class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]"
|
||||||
<div class="buttons">
|
@mouseenter="handleMouseEnter"
|
||||||
<!-- <div class="music-buttons">
|
@mouseleave="handleMouseLeave"
|
||||||
<div @click="handlePrev">
|
>
|
||||||
<i class="iconfont icon-prev"></i>
|
<!-- 顶部控制栏 -->
|
||||||
</div>
|
<div class="control-bar" :class="{ 'control-bar-show': showControls }">
|
||||||
<div class="music-buttons-play" @click="playMusicEvent">
|
<div class="font-size-controls">
|
||||||
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
|
<n-button-group>
|
||||||
</div>
|
<n-button quaternary size="small" :disabled="fontSize <= 12" @click="decreaseFontSize">
|
||||||
<div @click="handleEnded">
|
<i class="ri-subtract-line"></i>
|
||||||
<i class="iconfont icon-next"></i>
|
</n-button>
|
||||||
</div>
|
<n-button quaternary size="small" :disabled="fontSize >= 48" @click="increaseFontSize">
|
||||||
</div> -->
|
<i class="ri-add-line"></i>
|
||||||
<div class="button check-theme" @click="checkTheme">
|
</n-button>
|
||||||
<i v-if="lyricSetting.theme === 'light'" class="icon ri-sun-line"></i>
|
</n-button-group>
|
||||||
<i v-else class="icon ri-moon-line"></i>
|
</div>
|
||||||
|
<div class="control-buttons">
|
||||||
|
<div class="control-button" @click="checkTheme">
|
||||||
|
<i v-if="lyricSetting.theme === 'light'" class="ri-sun-line"></i>
|
||||||
|
<i v-else class="ri-moon-line"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="button">
|
<div class="control-button" @click="handleTop">
|
||||||
<i class="icon ri-share-2-line" :class="{ checked: lyricSetting.isTop }" @click="handleTop"></i>
|
<i class="ri-pushpin-line" :class="{ active: lyricSetting.isTop }"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="button button-lock" @click="handleLock">
|
<div class="control-button" @click="handleLock">
|
||||||
<i v-if="lyricSetting.isLock" class="icon ri-lock-line"></i>
|
<i v-if="lyricSetting.isLock" class="ri-lock-line"></i>
|
||||||
<i v-else class="icon ri-lock-unlock-line"></i>
|
<i v-else class="ri-lock-unlock-line"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="button">
|
<div class="control-button" @click="handleClose">
|
||||||
<i class="icon ri-close-circle-line" @click="handleClose"></i>
|
<i class="ri-close-line"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="clickThroughElement" class="lyric-box">
|
|
||||||
<template v-if="lyricData.lrcArray[lyricData.nowIndex]">
|
<!-- 歌词显示区域 -->
|
||||||
<h2 class="lyric lyric-current">{{ lyricData.lrcArray[lyricData.nowIndex].text }}</h2>
|
<div ref="containerRef" class="lyric-container">
|
||||||
<p class="lyric-current">{{ lyricData.currentLrc.trText }}</p>
|
<div class="lyric-scroll">
|
||||||
<template v-if="lyricData.lrcArray[lyricData.nowIndex + 1]">
|
<div class="lyric-wrapper" :style="wrapperStyle">
|
||||||
<h2 class="lyric lyric-next">
|
<template v-if="staticData.lrcArray?.length > 0">
|
||||||
{{ lyricData.lrcArray[lyricData.nowIndex + 1].text }}
|
<div
|
||||||
</h2>
|
v-for="(line, index) in staticData.lrcArray"
|
||||||
<p class="lyric-next">{{ lyricData.nextLrc.trText }}</p>
|
:key="index"
|
||||||
</template>
|
class="lyric-line"
|
||||||
</template>
|
:style="lyricLineStyle"
|
||||||
|
:class="{
|
||||||
|
'lyric-line-current': index === currentIndex,
|
||||||
|
'lyric-line-passed': index < currentIndex,
|
||||||
|
'lyric-line-next': index === currentIndex + 1,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="lyric-text" :style="{ fontSize: `${fontSize}px` }">
|
||||||
|
<span class="lyric-text-inner" :style="getLyricStyle(index)">
|
||||||
|
{{ line.text || '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="line.trText" class="lyric-translation" :style="{ fontSize: `${fontSize * 0.6}px` }">
|
||||||
|
{{ line.trText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="lyric-empty">暂无歌词</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useIpcRenderer } from '@vueuse/electron';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Lyric',
|
name: 'Lyric',
|
||||||
});
|
});
|
||||||
|
|
||||||
const ipcRenderer = useIpcRenderer();
|
const windowData = window as any;
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const containerHeight = ref(0);
|
||||||
|
const lineHeight = ref(60);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const isInitialized = ref(false);
|
||||||
|
// 字体大小控制
|
||||||
|
const fontSize = ref(24); // 默认字体大小
|
||||||
|
const fontSizeStep = 2; // 每次整的步长
|
||||||
|
|
||||||
const lyricData = ref({
|
// 静态数据
|
||||||
currentLrc: {
|
const staticData = ref<{
|
||||||
text: '',
|
lrcArray: Array<{ text: string; trText: string }>;
|
||||||
trText: '',
|
lrcTimeArray: number[];
|
||||||
},
|
allTime: number;
|
||||||
nextLrc: {
|
}>({
|
||||||
text: '',
|
lrcArray: [],
|
||||||
trText: '',
|
lrcTimeArray: [],
|
||||||
},
|
|
||||||
currentTime: 0,
|
|
||||||
nextTime: 0,
|
|
||||||
nowTime: 0,
|
|
||||||
allTime: 0,
|
allTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动态数据
|
||||||
|
const dynamicData = ref({
|
||||||
|
nowTime: 0,
|
||||||
startCurrentTime: 0,
|
startCurrentTime: 0,
|
||||||
lrcArray: [] as any,
|
nextTime: 0,
|
||||||
lrcTimeArray: [] as any,
|
isPlay: true,
|
||||||
nowIndex: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const lyricSetting = ref({
|
const lyricSetting = ref({
|
||||||
@@ -83,16 +114,323 @@ const lyricSetting = ref({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let hideControlsTimer: number | null = null;
|
||||||
|
|
||||||
|
const isHovering = ref(false);
|
||||||
|
|
||||||
|
// 计算是否栏
|
||||||
|
const showControls = computed(() => {
|
||||||
|
if (lyricSetting.value.isLock) {
|
||||||
|
return isHovering.value;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清除隐藏定时器
|
||||||
|
const clearHideTimer = () => {
|
||||||
|
if (hideControlsTimer) {
|
||||||
|
clearTimeout(hideControlsTimer);
|
||||||
|
hideControlsTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理鼠标进入窗口
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!lyricSetting.value.isLock) return;
|
||||||
|
isHovering.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理鼠标离开窗口
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!lyricSetting.value.isLock) return;
|
||||||
|
isHovering.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听锁定状态变化
|
||||||
|
watch(
|
||||||
|
() => lyricSetting.value.isLock,
|
||||||
|
(newLock: boolean) => {
|
||||||
|
if (newLock) {
|
||||||
|
isHovering.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
ipcRenderer.on('receive-lyric', (event, data) => {
|
// 初始化时,如果是锁定状态,确保控制栏隐藏
|
||||||
|
if (lyricSetting.value.isLock) {
|
||||||
|
isHovering.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearHideTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算歌词滚动位置
|
||||||
|
const wrapperStyle = computed(() => {
|
||||||
|
if (!isInitialized.value || !containerHeight.value) {
|
||||||
|
return {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
transition: 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算容器中心点
|
||||||
|
const containerCenter = containerHeight.value / 2;
|
||||||
|
|
||||||
|
// 计算当前行到顶部的距离(包含padding)
|
||||||
|
const currentLineTop = currentIndex.value * lineHeight.value + containerHeight.value * 0.2; // 加上顶部padding
|
||||||
|
|
||||||
|
// 计算偏移量,使当前行居中
|
||||||
|
const targetOffset = containerCenter - currentLineTop;
|
||||||
|
|
||||||
|
// 计算内容总高度(包含padding)
|
||||||
|
const contentHeight = staticData.value.lrcArray.length * lineHeight.value + containerHeight.value * 0.4; // 上下padding各20vh
|
||||||
|
|
||||||
|
// 计算最小和最大偏移量
|
||||||
|
const minOffset = -(contentHeight - containerHeight.value);
|
||||||
|
const maxOffset = 0;
|
||||||
|
|
||||||
|
// 限制偏移量在合理范围内
|
||||||
|
const finalOffset = Math.min(maxOffset, Math.max(minOffset, targetOffset));
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `translateY(${finalOffset}px)`,
|
||||||
|
transition: isInitialized.value ? 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const lyricLineStyle = computed(() => ({
|
||||||
|
height: `${lineHeight.value}px`,
|
||||||
|
}));
|
||||||
|
// 更新容器高度和行高
|
||||||
|
const updateContainerHeight = () => {
|
||||||
|
if (!containerRef.value) return;
|
||||||
|
|
||||||
|
// 更新容器高度
|
||||||
|
containerHeight.value = containerRef.value.clientHeight;
|
||||||
|
|
||||||
|
// 计算基础行高(字体大小的2.5倍)
|
||||||
|
const baseLineHeight = fontSize.value * 2.5;
|
||||||
|
|
||||||
|
// 计算最大允许行高(容器高度的1/4)
|
||||||
|
const maxAllowedHeight = containerHeight.value / 3;
|
||||||
|
|
||||||
|
// 设置行高(不小于40px,不大于最大允许高度)
|
||||||
|
lineHeight.value = Math.min(maxAllowedHeight, Math.max(40, baseLineHeight));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理字体大小变化
|
||||||
|
const handleFontSizeChange = async () => {
|
||||||
|
// 先保存字体大小
|
||||||
|
saveFontSize();
|
||||||
|
|
||||||
|
// 更新容器高度和行高
|
||||||
|
updateContainerHeight();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 增加字体大小
|
||||||
|
const increaseFontSize = async () => {
|
||||||
|
if (fontSize.value < 48) {
|
||||||
|
fontSize.value += fontSizeStep;
|
||||||
|
await handleFontSizeChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 减小字体大小
|
||||||
|
const decreaseFontSize = async () => {
|
||||||
|
if (fontSize.value > 12) {
|
||||||
|
fontSize.value -= fontSizeStep;
|
||||||
|
await handleFontSizeChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存字体大小到本地存储
|
||||||
|
const saveFontSize = () => {
|
||||||
|
localStorage.setItem('lyricFontSize', fontSize.value.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听容器大小变化
|
||||||
|
onMounted(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateContainerHeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObserver.observe(containerRef.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动画帧ID
|
||||||
|
const animationFrameId = ref<number | null>(null);
|
||||||
|
|
||||||
|
// 实际播放时间
|
||||||
|
const actualTime = ref(0);
|
||||||
|
|
||||||
|
// 计算当前行的进度
|
||||||
|
const currentProgress = computed(() => {
|
||||||
|
const { startCurrentTime, nextTime, isPlay } = dynamicData.value;
|
||||||
|
if (!startCurrentTime || !nextTime || !isPlay) return 0;
|
||||||
|
|
||||||
|
const duration = nextTime - startCurrentTime;
|
||||||
|
const elapsed = actualTime.value - startCurrentTime;
|
||||||
|
return Math.min(Math.max(elapsed / duration, 0), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取歌词样式
|
||||||
|
const getLyricStyle = (index: number) => {
|
||||||
|
if (index !== currentIndex.value) return {};
|
||||||
|
|
||||||
|
const progress = currentProgress.value * 100;
|
||||||
|
return {
|
||||||
|
background: `linear-gradient(to right, var(--highlight-color) ${progress}%, var(--text-color) ${progress}%)`,
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
transition: 'all 0.1s linear',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间偏移量(毫秒)
|
||||||
|
const TIME_OFFSET = 400;
|
||||||
|
|
||||||
|
// 更新动画
|
||||||
|
const updateProgress = () => {
|
||||||
|
if (!dynamicData.value.isPlay) {
|
||||||
|
if (animationFrameId.value) {
|
||||||
|
cancelAnimationFrame(animationFrameId.value);
|
||||||
|
animationFrameId.value = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算实际时间,添加偏移量
|
||||||
|
const timeDiff = (performance.now() - lastUpdateTime.value) / 1000;
|
||||||
|
actualTime.value = dynamicData.value.nowTime + timeDiff + TIME_OFFSET / 1000;
|
||||||
|
|
||||||
|
// 继续动画
|
||||||
|
animationFrameId.value = requestAnimationFrame(updateProgress);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 记录上次更新时间
|
||||||
|
const lastUpdateTime = ref(performance.now());
|
||||||
|
|
||||||
|
// 监听数据更新
|
||||||
|
watch(
|
||||||
|
() => dynamicData.value,
|
||||||
|
(newData: any) => {
|
||||||
|
// 更新最后更新时间
|
||||||
|
lastUpdateTime.value = performance.now();
|
||||||
|
|
||||||
|
// 更新实际时间,包含偏移量
|
||||||
|
actualTime.value = newData.nowTime + TIME_OFFSET / 1000;
|
||||||
|
|
||||||
|
// 如果正在播放且没有动画,启动动画
|
||||||
|
if (newData.isPlay && !animationFrameId.value) {
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听播放状态变化
|
||||||
|
watch(
|
||||||
|
() => dynamicData.value.isPlay,
|
||||||
|
(isPlaying: boolean) => {
|
||||||
|
if (isPlaying) {
|
||||||
|
lastUpdateTime.value = performance.now();
|
||||||
|
updateProgress();
|
||||||
|
} else if (animationFrameId.value) {
|
||||||
|
cancelAnimationFrame(animationFrameId.value);
|
||||||
|
animationFrameId.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修改数据更新处理
|
||||||
|
const handleDataUpdate = (parsedData: {
|
||||||
|
nowTime: number;
|
||||||
|
startCurrentTime: number;
|
||||||
|
nextTime: number;
|
||||||
|
isPlay: boolean;
|
||||||
|
nowIndex: number;
|
||||||
|
}) => {
|
||||||
|
// 确保数据存在且格式正确
|
||||||
|
if (!parsedData || typeof parsedData.nowTime !== 'number') {
|
||||||
|
console.error('Invalid update data received:', parsedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicData.value = {
|
||||||
|
nowTime: parsedData.nowTime,
|
||||||
|
startCurrentTime: parsedData.startCurrentTime,
|
||||||
|
nextTime: parsedData.nextTime,
|
||||||
|
isPlay: parsedData.isPlay,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新索引
|
||||||
|
if (typeof parsedData.nowIndex === 'number' && parsedData.nowIndex !== currentIndex.value) {
|
||||||
|
currentIndex.value = parsedData.nowIndex;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 加载保存的字体大小
|
||||||
|
const savedFontSize = localStorage.getItem('lyricFontSize');
|
||||||
|
if (savedFontSize) {
|
||||||
|
fontSize.value = Number(savedFontSize);
|
||||||
|
lineHeight.value = fontSize.value * 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化容器高度
|
||||||
|
updateContainerHeight();
|
||||||
|
window.addEventListener('resize', updateContainerHeight);
|
||||||
|
|
||||||
|
// 监听歌词数据
|
||||||
|
windowData.electron.ipcRenderer.on('receive-lyric', (data: string) => {
|
||||||
try {
|
try {
|
||||||
lyricData.value = JSON.parse(data);
|
const parsedData = JSON.parse(data);
|
||||||
|
if (parsedData.type === 'init') {
|
||||||
|
// 初始化重置状态
|
||||||
|
currentIndex.value = 0;
|
||||||
|
isInitialized.value = false;
|
||||||
|
|
||||||
|
// 清理可能存在的动画
|
||||||
|
if (animationFrameId.value) {
|
||||||
|
cancelAnimationFrame(animationFrameId.value);
|
||||||
|
animationFrameId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保据格式正确
|
||||||
|
if (Array.isArray(parsedData.lrcArray)) {
|
||||||
|
staticData.value = {
|
||||||
|
lrcArray: parsedData.lrcArray,
|
||||||
|
lrcTimeArray: parsedData.lrcTimeArray || [],
|
||||||
|
allTime: parsedData.allTime || 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.error('Invalid lyric array format:', parsedData);
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
isInitialized.value = true;
|
||||||
|
});
|
||||||
|
} else if (parsedData.type === 'update') {
|
||||||
|
handleDataUpdate(parsedData);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('error', error);
|
console.error('Error parsing lyric data:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateContainerHeight);
|
||||||
|
});
|
||||||
|
|
||||||
const checkTheme = () => {
|
const checkTheme = () => {
|
||||||
if (lyricSetting.value.theme === 'light') {
|
if (lyricSetting.value.theme === 'light') {
|
||||||
lyricSetting.value.theme = 'dark';
|
lyricSetting.value.theme = 'dark';
|
||||||
@@ -103,7 +441,7 @@ const checkTheme = () => {
|
|||||||
|
|
||||||
const handleTop = () => {
|
const handleTop = () => {
|
||||||
lyricSetting.value.isTop = !lyricSetting.value.isTop;
|
lyricSetting.value.isTop = !lyricSetting.value.isTop;
|
||||||
ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
windowData.electron.ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLock = () => {
|
const handleLock = () => {
|
||||||
@@ -111,26 +449,16 @@ const handleLock = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
ipcRenderer.send('close-lyric');
|
windowData.electron.ipcRenderer.send('close-lyric');
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => lyricSetting.value,
|
() => lyricSetting.value,
|
||||||
(newValue) => {
|
(newValue: any) => {
|
||||||
localStorage.setItem('lyricData', JSON.stringify(newValue));
|
localStorage.setItem('lyricData', JSON.stringify(newValue));
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// onMounted(() => {
|
|
||||||
// const el = document.getElementById('clickThroughElement') as HTMLElement;
|
|
||||||
// el.addEventListener('mouseenter', () => {
|
|
||||||
// if (lyricSetting.value.isLock) ipcRenderer.send('mouseenter-lyric');
|
|
||||||
// });
|
|
||||||
// el.addEventListener('mouseleave', () => {
|
|
||||||
// if (lyricSetting.value.isLock) ipcRenderer.send('mouseleave-lyric');
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -143,132 +471,196 @@ body {
|
|||||||
.lyric-window {
|
.lyric-window {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@apply overflow-hidden text-gray-600 rounded-xl box-border;
|
position: relative;
|
||||||
// border: 4px solid transparent;
|
overflow: hidden;
|
||||||
&:hover .lyric-bar {
|
background: transparent;
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
&:hover .drag-bar {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
&:hover {
|
|
||||||
box-shadow: inset 0 0 10px 0 rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyric_lock {
|
&.dark {
|
||||||
&:hover {
|
--bg-color: transparent;
|
||||||
box-shadow: none;
|
--text-color: #ffffff;
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||||
|
--highlight-color: #1db954;
|
||||||
|
--control-bg: rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
&:hover .lyric-bar {
|
|
||||||
background-color: transparent;
|
&.light {
|
||||||
.button {
|
--bg-color: transparent;
|
||||||
opacity: 0;
|
--text-color: #333333;
|
||||||
}
|
--text-secondary: rgba(51, 51, 51, 0.6);
|
||||||
.button-lock {
|
--highlight-color: #1db954;
|
||||||
opacity: 1;
|
--control-bg: rgba(255, 255, 255, 0.3);
|
||||||
color: #d6d6d6;
|
}
|
||||||
|
|
||||||
|
&.lyric_lock {
|
||||||
|
.control-bar {
|
||||||
|
background: var(--control-bg);
|
||||||
|
|
||||||
|
&-show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:hover .drag-bar {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.control-bar {
|
||||||
@apply text-xl hover:text-white;
|
position: absolute;
|
||||||
}
|
top: 0;
|
||||||
|
left: 0;
|
||||||
.lyric-bar {
|
right: 0;
|
||||||
background-color: #b1b1b1;
|
|
||||||
@apply flex flex-col justify-center items-center;
|
|
||||||
width: 100vw;
|
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
background: var(--control-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
visibility 0.2s ease;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
&-show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-size-controls {
|
||||||
|
margin-right: auto; // 将字体控制放在侧
|
||||||
|
padding-right: 20px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
|
||||||
|
.n-button {
|
||||||
|
i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
background: var(--control-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 18px;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-wrapper {
|
||||||
|
will-change: transform;
|
||||||
|
padding: 20vh 0;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-line {
|
||||||
|
padding: 4px 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.lyric-line-current {
|
||||||
|
transform: scale(1.05);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.lyric-line-passed,
|
||||||
|
&.lyric-line-next {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.lyric-bar-hover {
|
|
||||||
|
.lyric-text {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-translation {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: font-size 0.2s ease;
|
||||||
|
line-height: 1.4; // 添加行高比例
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: transparent !important;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||||
|
'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-content {
|
||||||
|
transition: font-size 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-line-current {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-bar {
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
height: 20px;
|
|
||||||
cursor: move;
|
|
||||||
background-color: #383838;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.buttons {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100px;
|
|
||||||
@apply flex justify-center items-center gap-4;
|
|
||||||
}
|
|
||||||
.button {
|
|
||||||
@apply cursor-pointer text-center;
|
|
||||||
}
|
|
||||||
.checked {
|
|
||||||
color: #fff !important;
|
|
||||||
}
|
|
||||||
.button-move {
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
.music-buttons {
|
|
||||||
@apply mx-6;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-2xl hover:text-green-500 transition;
|
|
||||||
}
|
|
||||||
|
|
||||||
@apply flex items-center;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-play {
|
|
||||||
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
|
|
||||||
background: #383838;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.check-theme {
|
|
||||||
font-size: 26px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyric {
|
|
||||||
text-shadow: 0 0 1vw #2c2c2c;
|
|
||||||
font-size: 4vw;
|
|
||||||
@apply font-bold m-0 p-0 select-none pointer-events-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyric-current {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyric-next {
|
|
||||||
color: #999;
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lyric-window.dark {
|
|
||||||
.lyric {
|
|
||||||
text-shadow: none;
|
|
||||||
text-shadow: 0 0 1vw #000000;
|
|
||||||
}
|
|
||||||
.lyric-current {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.lyric-next {
|
|
||||||
color: #cecece;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.lyric-box {
|
|
||||||
// writing-mode: vertical-rl;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
<div class="mv-list-title">
|
<div class="mv-list-title">
|
||||||
<h2>推荐MV</h2>
|
<h2>推荐MV</h2>
|
||||||
</div>
|
</div>
|
||||||
<n-scrollbar :size="100">
|
<n-scrollbar :size="100" @scroll="handleScroll">
|
||||||
<div class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
|
<div v-loading="initLoading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in mvList"
|
v-for="(item, index) in mvList"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="mv-item"
|
class="mv-item"
|
||||||
:class="setAnimationClass('animate__bounceIn')"
|
:class="setAnimationClass('animate__bounceIn')"
|
||||||
:style="setAnimationDelay(index, 30)"
|
:style="getItemAnimationDelay(index)"
|
||||||
>
|
>
|
||||||
<div class="mv-item-img" @click="handleShowMv(item)">
|
<div class="mv-item-img" @click="handleShowMv(item, index)">
|
||||||
<n-image
|
<n-image
|
||||||
class="mv-item-img-img"
|
class="mv-item-img-img"
|
||||||
:src="getImgUrl(item.cover, '200y112')"
|
:src="getImgUrl(item.cover, '200y112')"
|
||||||
@@ -28,28 +28,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mv-item-title">{{ item.name }}</div>
|
<div class="mv-item-title">{{ item.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||||
|
<div v-if="!hasMore && !initLoading" class="no-more">没有更多了</div>
|
||||||
</div>
|
</div>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
|
|
||||||
<n-drawer :show="showMv" height="100vh" placement="bottom" :z-index="999999999">
|
<mv-player
|
||||||
<div class="mv-detail">
|
v-model:show="showMv"
|
||||||
<video :src="playMvUrl" controls autoplay></video>
|
:current-mv="playMvItem"
|
||||||
<div class="mv-detail-title">
|
:is-prev-disabled="isPrevDisabled"
|
||||||
<div class="title">{{ playMvItem?.name }}</div>
|
@next="playNextMv"
|
||||||
<button @click="close">
|
@prev="playPrevMv"
|
||||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-drawer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import { getMvUrl, getTopMv } from '@/api/mv';
|
import { getTopMv } from '@/api/mv';
|
||||||
|
import MvPlayer from '@/components/MvPlayer.vue';
|
||||||
import { IMvItem } from '@/type/mv';
|
import { IMvItem } from '@/type/mv';
|
||||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||||
|
|
||||||
@@ -60,41 +60,116 @@ defineOptions({
|
|||||||
const showMv = ref(false);
|
const showMv = ref(false);
|
||||||
const mvList = ref<Array<IMvItem>>([]);
|
const mvList = ref<Array<IMvItem>>([]);
|
||||||
const playMvItem = ref<IMvItem>();
|
const playMvItem = ref<IMvItem>();
|
||||||
const playMvUrl = ref<string>();
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const initLoading = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const offset = ref(0);
|
||||||
|
const limit = ref(30);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
|
||||||
|
const getItemAnimationDelay = (index: number) => {
|
||||||
|
const currentPageIndex = index % limit.value;
|
||||||
|
return setAnimationDelay(currentPageIndex, 30);
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await getTopMv(30);
|
await loadMvList();
|
||||||
mvList.value = res.data.data;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleShowMv = async (item: IMvItem) => {
|
const handleShowMv = async (item: IMvItem, index: number) => {
|
||||||
store.commit('setIsPlay', false);
|
store.commit('setIsPlay', false);
|
||||||
store.commit('setPlayMusic', false);
|
store.commit('setPlayMusic', false);
|
||||||
showMv.value = true;
|
showMv.value = true;
|
||||||
const res = await getMvUrl(item.id);
|
currentIndex.value = index;
|
||||||
playMvItem.value = item;
|
playMvItem.value = item;
|
||||||
playMvUrl.value = res.data.data.url;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const playPrevMv = async (setLoading: (value: boolean) => void) => {
|
||||||
showMv.value = false;
|
try {
|
||||||
if (store.state.playMusicUrl) {
|
if (currentIndex.value > 0) {
|
||||||
store.commit('setIsPlay', true);
|
const prevItem = mvList.value[currentIndex.value - 1];
|
||||||
|
await handleShowMv(prevItem, currentIndex.value - 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const playNextMv = async (setLoading: (value: boolean) => void) => {
|
||||||
|
try {
|
||||||
|
if (currentIndex.value < mvList.value.length - 1) {
|
||||||
|
const nextItem = mvList.value[currentIndex.value + 1];
|
||||||
|
await handleShowMv(nextItem, currentIndex.value + 1);
|
||||||
|
} else if (hasMore.value) {
|
||||||
|
await loadMvList();
|
||||||
|
if (mvList.value.length > currentIndex.value + 1) {
|
||||||
|
const nextItem = mvList.value[currentIndex.value + 1];
|
||||||
|
await handleShowMv(nextItem, currentIndex.value + 1);
|
||||||
|
} else {
|
||||||
|
showMv.value = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showMv.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载更多MV失败:', error);
|
||||||
|
showMv.value = false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMvList = async () => {
|
||||||
|
if (!hasMore.value || loadingMore.value) return;
|
||||||
|
|
||||||
|
if (offset.value === 0) {
|
||||||
|
initLoading.value = true;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getTopMv(limit.value, offset.value);
|
||||||
|
if (offset.value === 0) {
|
||||||
|
mvList.value = res.data.data;
|
||||||
|
} else {
|
||||||
|
mvList.value.push(...res.data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore.value = res.data.data.length === limit.value;
|
||||||
|
offset.value += limit.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载MV失败:', error);
|
||||||
|
} finally {
|
||||||
|
initLoading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
const target = e.target as Element;
|
||||||
|
const { scrollTop, clientHeight, scrollHeight } = target;
|
||||||
|
const threshold = 100;
|
||||||
|
|
||||||
|
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
|
||||||
|
loadMvList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPrevDisabled = computed(() => currentIndex.value === 0);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.mv-list {
|
.mv-list {
|
||||||
@apply relative h-full w-full px-4;
|
@apply relative h-full w-full;
|
||||||
|
|
||||||
&-title {
|
&-title {
|
||||||
@apply text-xl font-bold;
|
@apply text-xl font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
@apply grid gap-6 pb-28 mt-2;
|
@apply grid gap-6 pb-28 mt-2 pr-4;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(14%, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(14%, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,36 +222,14 @@ const close = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mv-detail {
|
|
||||||
@apply w-full h-full bg-black relative;
|
|
||||||
|
|
||||||
&-title {
|
|
||||||
@apply absolute w-full left-0 flex justify-between h-16 px-6 py-2 text-xl font-bold items-center z-50 transition-all duration-300 ease-in-out -top-24;
|
|
||||||
background: linear-gradient(0, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%);
|
|
||||||
button .icon-xiasanjiaoxing {
|
|
||||||
@apply text-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
@apply text-green-400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
video {
|
|
||||||
@apply w-full h-full;
|
|
||||||
}
|
|
||||||
video:hover + .mv-detail-title {
|
|
||||||
@apply top-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mv-detail-title:hover {
|
|
||||||
@apply top-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile {
|
.mobile {
|
||||||
.mv-list-content {
|
.mv-list-content {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-more,
|
||||||
|
.no-more {
|
||||||
|
@apply col-span-full text-center py-4 text-gray-400;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
:class="setAnimationClass('animate__bounceInLeft')"
|
:class="setAnimationClass('animate__bounceInLeft')"
|
||||||
:style="setAnimationDelay(index, 10)"
|
:style="setAnimationDelay(index, 10)"
|
||||||
class="hot-search-item"
|
class="hot-search-item"
|
||||||
@click.stop="clickHotKeyword(item.searchWord)"
|
@click.stop="loadSearch(item.searchWord, 1)"
|
||||||
>
|
>
|
||||||
<span class="hot-search-item-count" :class="{ 'hot-search-item-count-3': index < 3 }">{{ index + 1 }}</span>
|
<span class="hot-search-item-count" :class="{ 'hot-search-item-count-3': index < 3 }">{{ index + 1 }}</span>
|
||||||
{{ item.searchWord }}
|
{{ item.searchWord }}
|
||||||
@@ -29,32 +29,30 @@
|
|||||||
:native-scrollbar="false"
|
:native-scrollbar="false"
|
||||||
>
|
>
|
||||||
<div class="title">{{ hotKeyword }}</div>
|
<div class="title">{{ hotKeyword }}</div>
|
||||||
<n-spin :show="searchDetailLoading">
|
<div v-loading="searchDetailLoading" class="search-list-box">
|
||||||
<div class="search-list-box">
|
<template v-if="searchDetail">
|
||||||
<template v-if="searchDetail">
|
<div
|
||||||
<div
|
v-for="(item, index) in searchDetail?.songs"
|
||||||
v-for="(item, index) in searchDetail?.songs"
|
:key="item.id"
|
||||||
:key="item.id"
|
:class="setAnimationClass('animate__bounceInRight')"
|
||||||
:class="setAnimationClass('animate__bounceInRight')"
|
:style="setAnimationDelay(index, 50)"
|
||||||
:style="setAnimationDelay(index, 50)"
|
>
|
||||||
>
|
<song-item :item="item" @play="handlePlay" />
|
||||||
<song-item :item="item" @play="handlePlay" />
|
</div>
|
||||||
</div>
|
<template v-for="(list, key) in searchDetail">
|
||||||
<template v-for="(list, key) in searchDetail">
|
<template v-if="key.toString() !== 'songs'">
|
||||||
<template v-if="key.toString() !== 'songs'">
|
<div
|
||||||
<div
|
v-for="(item, index) in list"
|
||||||
v-for="(item, index) in list"
|
:key="item.id"
|
||||||
:key="item.id"
|
:class="setAnimationClass('animate__bounceInRight')"
|
||||||
:class="setAnimationClass('animate__bounceInRight')"
|
:style="setAnimationDelay(index, 50)"
|
||||||
:style="setAnimationDelay(index, 50)"
|
>
|
||||||
>
|
<SearchItem :item="item" />
|
||||||
<SearchItem :item="item" />
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</template>
|
||||||
</n-spin>
|
</div>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -62,7 +60,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useDateFormat } from '@vueuse/core';
|
import { useDateFormat } from '@vueuse/core';
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import { getHotSearch } from '@/api/home';
|
import { getHotSearch } from '@/api/home';
|
||||||
@@ -76,9 +74,10 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const store = useStore();
|
||||||
|
|
||||||
const searchDetail = ref<any>();
|
const searchDetail = ref<any>();
|
||||||
const searchType = ref(Number(route.query.type) || 1);
|
const searchType = computed(() => store.state.searchType as number);
|
||||||
const searchDetailLoading = ref(false);
|
const searchDetailLoading = ref(false);
|
||||||
|
|
||||||
// 热搜列表
|
// 热搜列表
|
||||||
@@ -90,29 +89,26 @@ const loadHotSearch = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadHotSearch();
|
loadHotSearch();
|
||||||
|
loadSearch(route.query.keyword);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hotKeyword = ref(route.query.keyword || '搜索列表');
|
const hotKeyword = ref(route.query.keyword || '搜索列表');
|
||||||
const clickHotKeyword = (keyword: string) => {
|
|
||||||
hotKeyword.value = keyword;
|
watch(
|
||||||
router.push({
|
() => store.state.searchValue,
|
||||||
path: '/search',
|
(value) => {
|
||||||
query: {
|
loadSearch(value);
|
||||||
keyword,
|
},
|
||||||
type: 1,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
// isHotSearchList.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
||||||
const loadSearch = async (keywords: any) => {
|
const loadSearch = async (keywords: any, type: any = null) => {
|
||||||
hotKeyword.value = keywords;
|
hotKeyword.value = keywords;
|
||||||
searchDetail.value = undefined;
|
searchDetail.value = undefined;
|
||||||
if (!keywords) return;
|
if (!keywords) return;
|
||||||
|
|
||||||
searchDetailLoading.value = true;
|
searchDetailLoading.value = true;
|
||||||
const { data } = await getSearch({ keywords, type: searchType.value });
|
const { data } = await getSearch({ keywords, type: type || searchType.value });
|
||||||
|
|
||||||
const songs = data.result.songs || [];
|
const songs = data.result.songs || [];
|
||||||
const albums = data.result.albums || [];
|
const albums = data.result.albums || [];
|
||||||
@@ -135,7 +131,6 @@ const loadSearch = async (keywords: any) => {
|
|||||||
// songs map 替换属性
|
// songs map 替换属性
|
||||||
songs.forEach((item: any) => {
|
songs.forEach((item: any) => {
|
||||||
item.picUrl = item.al.picUrl;
|
item.picUrl = item.al.picUrl;
|
||||||
item.song = item;
|
|
||||||
item.artists = item.ar;
|
item.artists = item.ar;
|
||||||
});
|
});
|
||||||
albums.forEach((item: any) => {
|
albums.forEach((item: any) => {
|
||||||
@@ -151,18 +146,15 @@ const loadSearch = async (keywords: any) => {
|
|||||||
searchDetailLoading.value = false;
|
searchDetailLoading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
loadSearch(route.query.keyword);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.path,
|
||||||
async (newParams) => {
|
async (path) => {
|
||||||
searchType.value = Number(newParams.type || 1);
|
if (path === '/search') {
|
||||||
loadSearch(newParams.keyword);
|
store.state.searchValue = route.query.keyword;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
const tracks = searchDetail.value?.songs || [];
|
const tracks = searchDetail.value?.songs || [];
|
||||||
store.commit('setPlayList', tracks);
|
store.commit('setPlayList', tracks);
|
||||||
|
|||||||
@@ -7,12 +7,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<n-switch v-model:value="setData.isProxy" />
|
<n-switch v-model:value="setData.isProxy" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-item">
|
||||||
|
<div>
|
||||||
|
<div class="set-item-title">减轻动画效果</div>
|
||||||
|
</div>
|
||||||
|
<n-switch v-model:value="setData.noAnimate" />
|
||||||
|
</div>
|
||||||
<div class="set-item">
|
<div class="set-item">
|
||||||
<div>
|
<div>
|
||||||
<div class="set-item-title">版本</div>
|
<div class="set-item-title">版本</div>
|
||||||
<div class="set-item-content">当前已是最新版本</div>
|
<div class="set-item-content">当前已是最新版本</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ setData.version }}</div>
|
<div>{{ config.version }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-item">
|
<div class="set-item">
|
||||||
<div>
|
<div>
|
||||||
@@ -32,6 +38,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import config from '@/../package.json';
|
||||||
import store from '@/store';
|
import store from '@/store';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -49,7 +56,9 @@ const windowData = window as any;
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
store.commit('setSetData', setData.value);
|
store.commit('setSetData', setData.value);
|
||||||
windowData.electronAPI.restart();
|
if (windowData.electronAPI) {
|
||||||
|
windowData.electronAPI.restart();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { onBeforeRouteUpdate, useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
import { getListDetail } from '@/api/list';
|
import { getListDetail } from '@/api/list';
|
||||||
@@ -21,6 +21,7 @@ const router = useRouter();
|
|||||||
const userDetail = ref<IUserDetail>();
|
const userDetail = ref<IUserDetail>();
|
||||||
const playList = ref<any[]>([]);
|
const playList = ref<any[]>([]);
|
||||||
const recordList = ref();
|
const recordList = ref();
|
||||||
|
const infoLoading = ref(false);
|
||||||
|
|
||||||
const user = computed(() => store.state.user);
|
const user = computed(() => store.state.user);
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ const loadPage = async () => {
|
|||||||
router.push('/login');
|
router.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
infoLoading.value = true;
|
||||||
|
|
||||||
const { data: userData } = await getUserDetail(user.value.userId);
|
const { data: userData } = await getUserDetail(user.value.userId);
|
||||||
userDetail.value = userData;
|
userDetail.value = userData;
|
||||||
@@ -37,7 +39,12 @@ const loadPage = async () => {
|
|||||||
playList.value = playlistData.playlist;
|
playList.value = playlistData.playlist;
|
||||||
|
|
||||||
const { data: recordData } = await getUserRecord(user.value.userId);
|
const { data: recordData } = await getUserRecord(user.value.userId);
|
||||||
recordList.value = recordData.allData;
|
recordList.value = recordData.allData.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
...item.song,
|
||||||
|
picUrl: item.song.al.picUrl,
|
||||||
|
}));
|
||||||
|
infoLoading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
@@ -50,26 +57,20 @@ onActivated(() => {
|
|||||||
|
|
||||||
const isShowList = ref(false);
|
const isShowList = ref(false);
|
||||||
const list = ref<Playlist>();
|
const list = ref<Playlist>();
|
||||||
|
const listLoading = ref(false);
|
||||||
// 展示歌单
|
// 展示歌单
|
||||||
const showPlaylist = async (id: number) => {
|
const showPlaylist = async (id: number, name: string) => {
|
||||||
const { data } = await getListDetail(id);
|
|
||||||
isShowList.value = true;
|
isShowList.value = true;
|
||||||
|
listLoading.value = true;
|
||||||
|
|
||||||
|
list.value = {
|
||||||
|
name,
|
||||||
|
} as Playlist;
|
||||||
|
const { data } = await getListDetail(id);
|
||||||
list.value = data.playlist;
|
list.value = data.playlist;
|
||||||
|
listLoading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化歌曲列表项
|
|
||||||
const formatDetail = computed(() => (detail: any) => {
|
|
||||||
const song = {
|
|
||||||
artists: detail.ar,
|
|
||||||
name: detail.al.name,
|
|
||||||
id: detail.al.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
detail.song = song;
|
|
||||||
detail.picUrl = detail.al.picUrl;
|
|
||||||
return detail;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
const tracks = recordList.value || [];
|
const tracks = recordList.value || [];
|
||||||
store.commit('setPlayList', tracks);
|
store.commit('setPlayList', tracks);
|
||||||
@@ -77,7 +78,7 @@ const handlePlay = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-page" @click.stop="isShowList = false">
|
<div class="user-page">
|
||||||
<div
|
<div
|
||||||
v-if="userDetail"
|
v-if="userDetail"
|
||||||
class="left"
|
class="left"
|
||||||
@@ -106,9 +107,14 @@ const handlePlay = () => {
|
|||||||
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
|
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
|
||||||
|
|
||||||
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
|
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
|
||||||
<div class=" ">创建的歌单</div>
|
<div class="title">创建的歌单</div>
|
||||||
<n-scrollbar>
|
<n-scrollbar>
|
||||||
<div v-for="(item, index) in playList" :key="index" class="play-list-item" @click="showPlaylist(item.id)">
|
<div
|
||||||
|
v-for="(item, index) in playList"
|
||||||
|
:key="index"
|
||||||
|
class="play-list-item"
|
||||||
|
@click="showPlaylist(item.id, item.name)"
|
||||||
|
>
|
||||||
<n-image :src="getImgUrl(item.coverImgUrl, '50y50')" class="play-list-item-img" lazy preview-disabled />
|
<n-image :src="getImgUrl(item.coverImgUrl, '50y50')" class="play-list-item-img" lazy preview-disabled />
|
||||||
<div class="play-list-item-info">
|
<div class="play-list-item-info">
|
||||||
<div class="play-list-item-name">{{ item.name }}</div>
|
<div class="play-list-item-name">{{ item.name }}</div>
|
||||||
@@ -120,25 +126,31 @@ const handlePlay = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isMobile" class="right" :class="setAnimationClass('animate__fadeInRight')">
|
<div v-if="!isMobile" v-loading="infoLoading" class="right" :class="setAnimationClass('animate__fadeInRight')">
|
||||||
<div class="title">听歌排行</div>
|
<div class="title">听歌排行</div>
|
||||||
<div class="record-list">
|
<div class="record-list">
|
||||||
<n-scrollbar>
|
<n-scrollbar>
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in recordList"
|
v-for="(item, index) in recordList"
|
||||||
:key="item.song.id"
|
:key="item.id"
|
||||||
class="record-item"
|
class="record-item"
|
||||||
:class="setAnimationClass('animate__bounceInUp')"
|
:class="setAnimationClass('animate__bounceInUp')"
|
||||||
:style="setAnimationDelay(index, 50)"
|
:style="setAnimationDelay(index, 25)"
|
||||||
>
|
>
|
||||||
<song-item class="song-item" :item="formatDetail(item.song)" @play="handlePlay" />
|
<song-item class="song-item" :item="item" @play="handlePlay" />
|
||||||
<div class="play-count">{{ item.playCount }}次</div>
|
<div class="play-count">{{ item.playCount }}次</div>
|
||||||
</div>
|
</div>
|
||||||
<play-bottom />
|
<play-bottom />
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<music-list v-if="list" v-model:show="isShowList" :name="list.name" :song-list="list.tracks" />
|
<music-list
|
||||||
|
v-model:show="isShowList"
|
||||||
|
:name="list?.name || ''"
|
||||||
|
:song-list="list?.tracks || []"
|
||||||
|
:list-info="list"
|
||||||
|
:loading="listLoading"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||||
darkMode: false, // or 'media' or 'class'
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
variants: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,18 +33,23 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
// 指定端口
|
// 指定端口
|
||||||
port: 4678,
|
port: 4488,
|
||||||
proxy: {
|
proxy: {
|
||||||
// with options
|
// with options
|
||||||
'/api': {
|
[process.env.VITE_API_PROXY as string]: {
|
||||||
target: 'http://110.42.251.190:9898',
|
target: process.env.VITE_API,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY}`), ''),
|
||||||
},
|
},
|
||||||
'/music': {
|
[process.env.VITE_API_MUSIC_PROXY as string]: {
|
||||||
target: 'http://110.42.251.190:4100',
|
target: process.env.VITE_API_MUSIC,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/music/, ''),
|
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_MUSIC_PROXY}`), ''),
|
||||||
|
},
|
||||||
|
[process.env.VITE_API_PROXY_MUSIC as string]: {
|
||||||
|
target: process.env.VITE_API_PROXY,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY_MUSIC}`), ''),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user