mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-05 15:40:50 +08:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f728191a8f | ||
|
|
dfa8b51a53 | ||
|
|
b2c13121fd | ||
|
|
d28adb61a4 | ||
|
|
9a7d5a3834 | ||
|
|
2037798fbe | ||
|
|
85bd0ad015 | ||
|
|
e1557a51a3 | ||
|
|
1ecc6f136f | ||
|
|
53b3061b03 | ||
|
|
3d2f6a2330 | ||
|
|
3b1470f28f | ||
|
|
100268448a | ||
|
|
51f67bb2c2 | ||
|
|
7be126cf5f | ||
|
|
f2f5d3ac15 | ||
|
|
34c45e0105 | ||
|
|
f9333f5f78 | ||
|
|
7365daf700 | ||
|
|
cebf313075 | ||
|
|
bb99049991 | ||
|
|
df74dafbc5 | ||
|
|
721d2a9704 | ||
|
|
1e60fa9a95 | ||
|
|
f24e8232f8 | ||
|
|
a1b1d861ac | ||
|
|
f24263b416 | ||
|
|
17795e5da2 | ||
|
|
f1030d3a78 | ||
|
|
b979ce250f | ||
|
|
d0d8966875 | ||
|
|
d39ba65263 | ||
|
|
62d400827e | ||
|
|
75b99c46b5 | ||
|
|
e7ae79144c | ||
|
|
04d6cbe7f3 | ||
|
|
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 |
@@ -1,3 +1,12 @@
|
||||
VITE_API = /api
|
||||
VITE_API_MUSIC = /music
|
||||
VITE_API_PROXY = http://110.42.251.190:9856
|
||||
# 你的接口地址 (必填)
|
||||
VITE_API_LOCAL = ***
|
||||
# 音乐破解接口地址
|
||||
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
|
||||
@@ -39,9 +39,13 @@
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"no-nested-ternary": "off",
|
||||
"no-console": "off",
|
||||
"no-continue": "off",
|
||||
"no-restricted-syntax": "off",
|
||||
"no-return-assign": "off",
|
||||
"no-unused-expressions": "off",
|
||||
"no-return-await": "off",
|
||||
"no-plusplus": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-shadow": "off",
|
||||
@@ -126,7 +130,10 @@
|
||||
"prefer-const": "error", // ts provides better types with const
|
||||
"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
|
||||
"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
|
||||
|
||||
.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/
|
||||
75
README.md
75
README.md
@@ -1,4 +1,4 @@
|
||||
# 一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||
# Alger Music Player
|
||||
主要功能如下
|
||||
|
||||
- 音乐推荐
|
||||
@@ -7,6 +7,44 @@
|
||||
- 播放历史
|
||||
- 桌面歌词
|
||||
- 歌单 mv 搜索 专辑等功能
|
||||
- 识别无法播放歌曲 并代理播放
|
||||
- 可听周杰伦(搜索专辑)
|
||||
|
||||
## 项目简介
|
||||
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||
|
||||
## 预览地址
|
||||
[http://mc.alger.fun/](http://mc.alger.fun/)
|
||||
|
||||
QQ群:789288579
|
||||
|
||||
## 软件截图
|
||||

|
||||

|
||||

|
||||
|
||||
## 技术栈
|
||||
|
||||
### 主要框架
|
||||
- Vue 3 - 渐进式 JavaScript 框架
|
||||
- TypeScript - JavaScript 的超集,添加了类型系统
|
||||
- Electron - 跨平台桌面应用开发框架
|
||||
- Vite - 下一代前端构建工具
|
||||
|
||||
### UI 框架
|
||||
- Naive UI - 基于 Vue 3 的组件库
|
||||
|
||||
### 项目特点
|
||||
- 完整的类型支持(TypeScript)
|
||||
- 模块化设计
|
||||
- 自动化组件和 API 导入
|
||||
- 多平台支持(Web、Desktop、Mobile Web)
|
||||
- 构建优化(代码分割、压缩)
|
||||
|
||||
## 咖啡☕️
|
||||
| 微信 | 支付宝 |
|
||||
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
|
||||
| <img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width=200> | <img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Wechat QRcode" width=200> |
|
||||
|
||||
## 项目运行
|
||||
```bash
|
||||
@@ -26,16 +64,37 @@
|
||||
npm run win ...
|
||||
# 具体看 package.json
|
||||
```
|
||||
#### 注意
|
||||
- 本地运行需要配置 .env.development 文件
|
||||
- 打包需要配置 .env.production 文件
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_API_LOCAL = /api
|
||||
VITE_API_MUSIC_PROXY = /music
|
||||
VITE_API_PROXY_MUSIC = /music_proxy
|
||||
|
||||
# 你的接口地址 (必填)
|
||||
VITE_API = ***
|
||||
# 音乐po接口地址
|
||||
VITE_API_MUSIC = ***
|
||||
VITE_API_PROXY = ***
|
||||
|
||||
|
||||
# .env.production
|
||||
# 你的接口地址 (必填)
|
||||
VITE_API = ***
|
||||
# 音乐po接口地址
|
||||
VITE_API_MUSIC = ***
|
||||
# 代理地址
|
||||
VITE_API_PROXY = ***
|
||||
```
|
||||
|
||||
## Stargazers over time
|
||||
[](https://starchart.cc/algerkong/AlgerMusicPlayer)
|
||||
|
||||
|
||||
|
||||
## 软件截图
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 欢迎提Issues
|
||||
|
||||
|
||||
13
app.js
13
app.js
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const Store = require('electron-store');
|
||||
const setJson = require('./electron/set.json');
|
||||
const { loadLyricWindow } = require('./electron/lyric');
|
||||
const config = require('./electron/config');
|
||||
|
||||
let mainWin = null;
|
||||
function createWindow() {
|
||||
@@ -11,8 +12,8 @@ function createWindow() {
|
||||
height: 780,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
// contextIsolation: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, '/electron/preload.js'),
|
||||
},
|
||||
});
|
||||
@@ -20,11 +21,13 @@ function createWindow() {
|
||||
win.setMinimumSize(1200, 780);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
win.loadURL('http://localhost:4678/');
|
||||
win.loadURL(`http://localhost:${config.development.mainPort}/`);
|
||||
} else {
|
||||
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);
|
||||
|
||||
// 创建一个上下文菜单
|
||||
@@ -63,7 +66,7 @@ function createWindow() {
|
||||
store.set('set', setJson);
|
||||
}
|
||||
|
||||
loadLyricWindow(ipcMain);
|
||||
loadLyricWindow(ipcMain, mainWin);
|
||||
}
|
||||
|
||||
// 限制只能启动一个应用
|
||||
|
||||
7
auto-imports.d.ts
vendored
7
auto-imports.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
@@ -35,6 +36,7 @@ declare global {
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
@@ -53,10 +55,13 @@ declare global {
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useDialog: typeof import('naive-ui')['useDialog']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
|
||||
const useMessage: typeof import('naive-ui')['useMessage']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useNotification: typeof import('naive-ui')['useNotification']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
@@ -65,6 +70,6 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @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')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
components.d.ts
vendored
11
components.d.ts
vendored
@@ -1,31 +1,40 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Coffee: typeof import('./src/components/Coffee.vue')['default']
|
||||
InstallAppModal: typeof import('./src/components/common/InstallAppModal.vue')['default']
|
||||
MPop: typeof import('./src/components/common/MPop.vue')['default']
|
||||
MusicList: typeof import('./src/components/MusicList.vue')['default']
|
||||
MvPlayer: typeof import('./src/components/MvPlayer.vue')['default']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSlider: typeof import('naive-ui')['NSlider']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
|
||||
PlayListsItem: typeof import('./src/components/common/PlayListsItem.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,60 +1,104 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
const { BrowserWindow, screen } = require('electron');
|
||||
const path = require('path');
|
||||
const Store = require('electron-store');
|
||||
const config = require('./config');
|
||||
|
||||
const store = new Store();
|
||||
let lyricWindow = null;
|
||||
|
||||
const createWin = () => {
|
||||
console.log('Creating lyric window');
|
||||
|
||||
// 获取保存的窗口位置
|
||||
const windowBounds = store.get('lyricWindowBounds') || {};
|
||||
const { x, y, width, height } = windowBounds;
|
||||
|
||||
// 获取屏幕尺寸
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
// 验证保存的位置是否有效
|
||||
const validPosition = x !== undefined && y !== undefined && x >= 0 && y >= 0 && x < screenWidth && y < screenHeight;
|
||||
|
||||
lyricWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 300,
|
||||
width: width || 800,
|
||||
height: height || 200,
|
||||
x: validPosition ? x : undefined,
|
||||
y: validPosition ? y : undefined,
|
||||
frame: false,
|
||||
show: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: `${__dirname}/preload.js`,
|
||||
contextIsolation: false,
|
||||
webSecurity: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 监听窗口关闭事件
|
||||
lyricWindow.on('closed', () => {
|
||||
console.log('Lyric window closed');
|
||||
lyricWindow = null;
|
||||
});
|
||||
};
|
||||
const loadLyricWindow = (ipcMain) => {
|
||||
|
||||
const loadLyricWindow = (ipcMain, mainWin) => {
|
||||
ipcMain.on('open-lyric', () => {
|
||||
console.log('Received open-lyric request');
|
||||
if (lyricWindow) {
|
||||
console.log('Lyric window exists, focusing');
|
||||
if (lyricWindow.isMinimized()) lyricWindow.restore();
|
||||
lyricWindow.focus();
|
||||
lyricWindow.show();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Creating new lyric window');
|
||||
createWin();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
lyricWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
lyricWindow.loadURL('http://localhost:4678/#/lyric');
|
||||
lyricWindow.loadURL(`http://localhost:${config.development.lyricPort}/#/lyric`);
|
||||
} else {
|
||||
const distPath = path.resolve(__dirname, '../dist');
|
||||
const distPath = path.resolve(__dirname, config.production.distPath);
|
||||
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
|
||||
}
|
||||
|
||||
lyricWindow.setMinimumSize(600, 200);
|
||||
|
||||
// 隐藏任务栏
|
||||
lyricWindow.setSkipTaskbar(true);
|
||||
|
||||
lyricWindow.show();
|
||||
lyricWindow.once('ready-to-show', () => {
|
||||
console.log('Lyric window ready to show');
|
||||
lyricWindow.show();
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('send-lyric', (e, data) => {
|
||||
if (lyricWindow) {
|
||||
lyricWindow.webContents.send('receive-lyric', data);
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
try {
|
||||
lyricWindow.webContents.send('receive-lyric', data);
|
||||
} catch (error) {
|
||||
console.error('Error processing lyric data:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('Cannot send lyric: window not available or destroyed');
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('top-lyric', (e, data) => {
|
||||
lyricWindow.setAlwaysOnTop(data);
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
lyricWindow.setAlwaysOnTop(data);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('close-lyric', () => {
|
||||
lyricWindow.close();
|
||||
lyricWindow = null;
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
lyricWindow.webContents.send('lyric-window-close');
|
||||
mainWin.webContents.send('lyric-control-back', 'close');
|
||||
lyricWindow.close();
|
||||
lyricWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('mouseenter-lyric', () => {
|
||||
@@ -64,6 +108,47 @@ const loadLyricWindow = (ipcMain) => {
|
||||
ipcMain.on('mouseleave-lyric', () => {
|
||||
lyricWindow.setIgnoreMouseEvents(false);
|
||||
});
|
||||
|
||||
// 处理拖动移动
|
||||
ipcMain.on('lyric-drag-move', (e, { deltaX, deltaY }) => {
|
||||
if (!lyricWindow) return;
|
||||
|
||||
const [currentX, currentY] = lyricWindow.getPosition();
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
|
||||
const [windowWidth, windowHeight] = lyricWindow.getSize();
|
||||
|
||||
// 计算新位置,确保窗口不会移出屏幕
|
||||
const newX = Math.max(0, Math.min(currentX + deltaX, screenWidth - windowWidth));
|
||||
const newY = Math.max(0, Math.min(currentY + deltaY, screenHeight - windowHeight));
|
||||
|
||||
lyricWindow.setPosition(newX, newY);
|
||||
|
||||
// 保存新位置
|
||||
store.set('lyricWindowBounds', {
|
||||
...lyricWindow.getBounds(),
|
||||
x: newX,
|
||||
y: newY,
|
||||
});
|
||||
});
|
||||
|
||||
// 添加鼠标穿透事件处理
|
||||
ipcMain.on('set-ignore-mouse', (e, shouldIgnore) => {
|
||||
if (!lyricWindow) return;
|
||||
|
||||
if (shouldIgnore) {
|
||||
// 设置鼠标穿透,但保留拖动区域可交互
|
||||
lyricWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
// 取消鼠标穿透
|
||||
lyricWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加播放控制处理
|
||||
ipcMain.on('control-back', (e, command) => {
|
||||
console.log('Received control-back request:', command);
|
||||
mainWin.webContents.send('lyric-control-back', command);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { contextBridge, ipcRenderer, app } = require('electron');
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 主进程通信
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
minimize: () => ipcRenderer.send('minimize-window'),
|
||||
maximize: () => ipcRenderer.send('maximize-window'),
|
||||
@@ -11,18 +12,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
|
||||
});
|
||||
|
||||
const electronHandler = {
|
||||
// 存储相关
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer: {
|
||||
setStoreValue: (key, value) => {
|
||||
ipcRenderer.send('setStore', key, value);
|
||||
setStoreValue: (key, value) => ipcRenderer.send('setStore', key, value),
|
||||
getStoreValue: (key) => ipcRenderer.sendSync('getStore', key),
|
||||
on: (channel, func) => {
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
},
|
||||
|
||||
getStoreValue(key) {
|
||||
const resp = ipcRenderer.sendSync('getStore', key);
|
||||
return resp;
|
||||
once: (channel, func) => {
|
||||
ipcRenderer.once(channel, (event, ...args) => func(...args));
|
||||
},
|
||||
send: (channel, data) => {
|
||||
ipcRenderer.send(channel, data);
|
||||
},
|
||||
},
|
||||
app,
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', electronHandler);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"isProxy": false,
|
||||
"author": "alger"
|
||||
"noAnimate": false,
|
||||
"animationSpeed": 1,
|
||||
"author": "Alger",
|
||||
"authorUrl": "https://github.com/algerkong"
|
||||
}
|
||||
|
||||
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;
|
||||
46
index.html
46
index.html
@@ -1,15 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>网抑云 | algerkong</title>
|
||||
<link rel="manifest" href="./public/manifest.json" />
|
||||
<link rel="stylesheet" href="./public/icon/iconfont.css" />
|
||||
<link rel="stylesheet" href="./public/css/animate.css" />
|
||||
<link rel="stylesheet" href="./public/css/base.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
|
||||
<!-- SEO 元数据 -->
|
||||
<title>网抑云音乐 | AlgerKong | AlgerMusicPlayer</title>
|
||||
<meta name="description"
|
||||
content="AlgerMusicPlayer 网抑云音乐 基于 网易云音乐API 的一款免费的在线音乐播放器,支持在线播放、歌词显示、音乐下载等功能。提供海量音乐资源,让您随时随地享受音乐。" />
|
||||
<meta name="keywords" content="AlgerMusic, AlgerMusicPlayer, 网抑云, 音乐播放器, 在线音乐, 免费音乐, 歌词显示, 音乐下载, AlgerKong, 网易云音乐" />
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<meta name="author" content="AlgerKong" />
|
||||
<meta name="author-url" content="https://github.com/algerkong" />
|
||||
|
||||
<!-- PWA 相关 -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="网抑云音乐" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- 资源预加载 -->
|
||||
<link rel="preload" href="/icon/iconfont.css" as="style" />
|
||||
<link rel="preload" href="/css/animate.css" as="style" />
|
||||
<link rel="preload" href="/css/base.css" as="style" />
|
||||
|
||||
<!-- 样式表 -->
|
||||
<link rel="stylesheet" href="/icon/iconfont.css" />
|
||||
<link rel="stylesheet" href="/css/animate.css" />
|
||||
<link rel="stylesheet" href="/css/base.css" />
|
||||
<script defer src="https://cn.vercount.one/js"></script>
|
||||
|
||||
<!-- 动画配置 -->
|
||||
<style>
|
||||
:root {
|
||||
--animate-delay: 0.5s;
|
||||
@@ -19,6 +45,12 @@
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div style="display: none;">
|
||||
Total Page View <span id="vercount_value_page_pv">Loading</span>
|
||||
Total Visits <span id="vercount_value_site_pv">Loading</span>
|
||||
Site Total Visitors <span id="vercount_value_site_uv">Loading</span>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
|
||||
65
package.json
65
package.json
@@ -1,36 +1,41 @@
|
||||
{
|
||||
"name": "alger-music",
|
||||
"version": "1.5.1",
|
||||
"version": "2.4.0",
|
||||
"description": "这是一个用于音乐播放的应用程序。",
|
||||
"author": "Alger <algerkc@qq.com>",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "cross-env NODE_ENV=production vite build",
|
||||
"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",
|
||||
"b:win:x64": "electron-builder --config ./build/win64.json",
|
||||
"b:win:x86": "electron-builder --config ./build/win32.json",
|
||||
"b:win:arm": "electron-builder --config ./build/winarm64.json"
|
||||
"b:win:x64": "cross-env NODE_ENV=production electron-builder --config ./build/win64.json",
|
||||
"b:win:x86": "cross-env NODE_ENV=production electron-builder --config ./build/win32.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": {
|
||||
"electron-store": "^8.1.0"
|
||||
"@types/howler": "^2.2.12",
|
||||
"electron-store": "^8.1.0",
|
||||
"howler": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss7-compat": "^2.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vue/compiler-sfc": "^3.3.4",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/runtime-core": "^3.3.4",
|
||||
"@vueuse/core": "^10.7.1",
|
||||
"@vueuse/electron": "^10.9.0",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"axios": "^0.21.1",
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.13.0",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"@vue/compiler-sfc": "^3.5.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/runtime-core": "^3.5.0",
|
||||
"@vueuse/core": "^11.0.3",
|
||||
"@vueuse/electron": "^11.0.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.7",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^32.2.7",
|
||||
"electron-builder": "^25.0.5",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
@@ -40,22 +45,22 @@
|
||||
"eslint-plugin-vue": "^9.21.1",
|
||||
"eslint-plugin-vue-scoped-css": "^2.7.2",
|
||||
"lodash": "^4.17.21",
|
||||
"naive-ui": "^2.38.2",
|
||||
"postcss": "^7.0.36",
|
||||
"prettier": "^3.2.5",
|
||||
"naive-ui": "^2.39.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"remixicon": "^4.2.0",
|
||||
"sass": "^1.35.2",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
||||
"typescript": "^4.3.2",
|
||||
"unplugin-auto-import": "^0.17.2",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"sass": "^1.82.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.5.4",
|
||||
"unplugin-auto-import": "^0.18.2",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vfonts": "^0.1.0",
|
||||
"vite": "^4.4.7",
|
||||
"vite": "^5.4.3",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-vue-devtools": "1.0.0-beta.5",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue-tsc": "^0.0.24",
|
||||
"vite-plugin-vue-devtools": "7.4.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.3",
|
||||
"vue-tsc": "^2.1.4",
|
||||
"vuex": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
body{
|
||||
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 |
31
src/App.vue
31
src/App.vue
@@ -1,40 +1,39 @@
|
||||
<template>
|
||||
<div class="app" :class="isMobile ? 'mobile' : ''">
|
||||
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
|
||||
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<n-dialog-provider>
|
||||
<keep-alive>
|
||||
<n-message-provider>
|
||||
<router-view></router-view>
|
||||
</keep-alive>
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { darkTheme } from 'naive-ui';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
import homeRouter from '@/router/home';
|
||||
import store from '@/store';
|
||||
|
||||
import { isMobile } from './utils';
|
||||
|
||||
const playMusicUrl = computed(() => store.state.playMusicUrl as string);
|
||||
// 是否播放
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
const windowData = window as any;
|
||||
onMounted(() => {
|
||||
if (windowData.electron) {
|
||||
const setData = windowData.electron.ipcRenderer.getStoreValue('set');
|
||||
store.commit('setSetData', setData);
|
||||
store.dispatch('initializeSettings');
|
||||
if (isMobile.value) {
|
||||
store.commit(
|
||||
'setMenus',
|
||||
homeRouter.filter((item) => item.meta.isMobile),
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.app {
|
||||
.app-container {
|
||||
@apply h-full w-full;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const getRecommendMusic = (params: IRecommendMusicParams) => {
|
||||
|
||||
// 获取每日推荐
|
||||
export const getDayRecommend = () => {
|
||||
return request.get<IData<IDayRecommend>>('/recommend/songs');
|
||||
return request.get<IData<IData<IDayRecommend>>>('/recommend/songs');
|
||||
};
|
||||
|
||||
// 获取最新专辑推荐
|
||||
|
||||
@@ -2,12 +2,27 @@ import { IData } from '@/type';
|
||||
import { IMvItem, IMvUrlData } from '@/type/mv';
|
||||
import request from '@/utils/request';
|
||||
|
||||
interface MvParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
area?: string;
|
||||
}
|
||||
|
||||
// 获取 mv 排行
|
||||
export const getTopMv = (limit: number) => {
|
||||
return request.get<IData<Array<IMvItem>>>('/top/mv', {
|
||||
params: {
|
||||
limit,
|
||||
},
|
||||
export const getTopMv = (params: MvParams) => {
|
||||
return request({
|
||||
url: '/mv/all',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取所有mv
|
||||
export const getAllMv = (params: MvParams) => {
|
||||
return request({
|
||||
url: '/mv/all',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
BIN
src/assets/alipay.png
Normal file
BIN
src/assets/alipay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/wechat.png
Normal file
BIN
src/assets/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
56
src/components/Coffee.vue
Normal file
56
src/components/Coffee.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<n-popover trigger="hover" placement="top" :show-arrow="true" :raw="true" :delay="100">
|
||||
<template #trigger>
|
||||
<slot>
|
||||
<n-button
|
||||
quaternary
|
||||
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
请我喝咖啡
|
||||
</n-button>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div class="p-6 bg-black rounded-lg shadow-lg">
|
||||
<div class="flex gap-10">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image :src="alipayQR" alt="支付宝收款码" class="w-32 h-32 rounded-lg cursor-none" preview-disabled />
|
||||
<span class="text-sm text-gray-100">支付宝</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image :src="wechatQR" alt="微信收款码" class="w-32 h-32 rounded-lg cursor-none" preview-disabled />
|
||||
<span class="text-sm text-gray-100">微信支付</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-gray-100 text-center cursor-pointer hover:text-green-500" @click="copyQQ">
|
||||
QQ群:789288579
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NImage, NPopover } from 'naive-ui';
|
||||
|
||||
const message = useMessage();
|
||||
const copyQQ = () => {
|
||||
navigator.clipboard.writeText('789288579');
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
defineProps({
|
||||
alipayQR: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
wechatQR: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,30 +1,73 @@
|
||||
<template>
|
||||
<n-drawer
|
||||
:show="show"
|
||||
:height="isMobile ? '100vh' : '70vh'"
|
||||
:height="isMobile ? '100%' : '80%'"
|
||||
placement="bottom"
|
||||
:drawer-style="{ backgroundColor: 'transparent' }"
|
||||
block-scroll
|
||||
mask-closable
|
||||
:style="{ backgroundColor: 'transparent' }"
|
||||
:to="`#layout-main`"
|
||||
@mask-click="close"
|
||||
>
|
||||
<div class="music-page">
|
||||
<i class="iconfont icon-icon_error music-close" @click="close"></i>
|
||||
<div class="music-title text-el">{{ name }}</div>
|
||||
<!-- 歌单歌曲列表 -->
|
||||
<div :show="loading" class="music-list">
|
||||
<n-scrollbar>
|
||||
<n-spin :show="loading">
|
||||
<div class="music-list-content">
|
||||
<div
|
||||
v-for="(item, index) in songList"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||
</div>
|
||||
<div class="music-header h-12 flex items-center justify-between">
|
||||
<n-ellipsis :line-clamp="1">
|
||||
<div class="music-title">
|
||||
{{ name }}
|
||||
</div>
|
||||
</n-ellipsis>
|
||||
<div class="music-close">
|
||||
<i class="icon iconfont icon-icon_error" @click="close"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-content">
|
||||
<!-- 左侧歌单信息 -->
|
||||
<div class="music-info">
|
||||
<div class="music-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '300y300')"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
:class="setAnimationClass('animate__fadeIn')"
|
||||
object-fit="cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="music-detail">
|
||||
<div v-if="listInfo?.creator" class="creator-info">
|
||||
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
|
||||
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
|
||||
</div>
|
||||
</n-spin>
|
||||
<div v-if="listInfo?.description" class="music-desc">
|
||||
<n-ellipsis :line-clamp="isMobile ? 3 : 10">
|
||||
{{ listInfo.description }}
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧歌曲列表 -->
|
||||
<div class="music-list-container">
|
||||
<div class="music-list">
|
||||
<n-scrollbar @scroll="handleScroll">
|
||||
<n-spin :show="loadingList || loading">
|
||||
<div class="music-list-content">
|
||||
<div
|
||||
v-for="(item, index) in displayedSongs"
|
||||
:key="item.id"
|
||||
class="double-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
>
|
||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||
</div>
|
||||
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
|
||||
<play-bottom />
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer>
|
||||
@@ -33,30 +76,48 @@
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
import PlayBottom from './common/PlayBottom.vue';
|
||||
|
||||
const loading = ref(true);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
name: string;
|
||||
songList: any[];
|
||||
}>();
|
||||
const emit = defineEmits(['update:show']);
|
||||
|
||||
watch(
|
||||
() => props.songList,
|
||||
(val) => {
|
||||
loading.value = !(val && val.length);
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
name: string;
|
||||
songList: any[];
|
||||
loading?: boolean;
|
||||
listInfo?: {
|
||||
trackIds: { id: number }[];
|
||||
[key: string]: any;
|
||||
};
|
||||
cover?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
cover: true,
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:show', 'update:loading']);
|
||||
|
||||
const page = ref(0);
|
||||
const pageSize = 20;
|
||||
const isLoadingMore = ref(false);
|
||||
const displayedSongs = ref<any[]>([]);
|
||||
const loadingList = ref(false);
|
||||
|
||||
// 计算总数
|
||||
const total = computed(() => {
|
||||
if (props.listInfo?.trackIds) {
|
||||
return props.listInfo.trackIds.length;
|
||||
}
|
||||
return props.songList.length;
|
||||
});
|
||||
|
||||
const formatDetail = computed(() => (detail: any) => {
|
||||
const song = {
|
||||
artists: detail.ar,
|
||||
@@ -71,32 +132,160 @@ const formatDetail = computed(() => (detail: any) => {
|
||||
|
||||
const handlePlay = () => {
|
||||
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 = () => {
|
||||
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;
|
||||
loadingList.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % pageSize;
|
||||
return setAnimationDelay(currentPageIndex, 20);
|
||||
};
|
||||
|
||||
// 修改滚动处理函数
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
loadingList.value = newVal;
|
||||
if (!props.cover) {
|
||||
loadingList.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听 songList 变化,重置分页状态
|
||||
watch(
|
||||
() => props.songList,
|
||||
(newSongs) => {
|
||||
page.value = 0;
|
||||
displayedSongs.value = newSongs.slice(0, pageSize);
|
||||
if (newSongs.length > pageSize) {
|
||||
page.value = 1;
|
||||
}
|
||||
loadingList.value = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.music {
|
||||
&-title {
|
||||
@apply text-xl font-bold text-white;
|
||||
}
|
||||
|
||||
&-page {
|
||||
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
&-title {
|
||||
@apply text-lg font-bold text-white p-4;
|
||||
}
|
||||
|
||||
&-close {
|
||||
@apply absolute top-4 right-8 cursor-pointer text-white text-3xl;
|
||||
@apply cursor-pointer text-white flex gap-2 items-center;
|
||||
.icon {
|
||||
@apply text-3xl;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply flex h-[calc(100%-60px)];
|
||||
}
|
||||
|
||||
&-info {
|
||||
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
|
||||
|
||||
.music-cover {
|
||||
@apply w-full aspect-square rounded-lg overflow-hidden mb-4;
|
||||
.cover-img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.music-detail {
|
||||
@apply flex flex-col flex-grow;
|
||||
|
||||
.creator-info {
|
||||
@apply flex items-center mb-4;
|
||||
.creator-name {
|
||||
@apply ml-2 text-sm text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.music-desc {
|
||||
@apply text-sm text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-list-container {
|
||||
@apply flex-grow min-h-0 flex flex-col relative;
|
||||
}
|
||||
|
||||
&-list {
|
||||
height: calc(100% - 60px);
|
||||
@apply flex-grow min-h-0;
|
||||
|
||||
&-content {
|
||||
min-height: 400px;
|
||||
@apply min-h-[calc(80vh-60px)];
|
||||
}
|
||||
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,5 +294,34 @@ const close = () => {
|
||||
.music-page {
|
||||
@apply px-4;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.music-info {
|
||||
@apply w-full pr-0 mb-2 flex flex-row;
|
||||
|
||||
.music-cover {
|
||||
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
|
||||
}
|
||||
.music-detail {
|
||||
@apply flex-1 ml-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply text-center text-white py-10;
|
||||
}
|
||||
|
||||
.double-list {
|
||||
.double-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
background-color: #191919;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
867
src/components/MvPlayer.vue
Normal file
867
src/components/MvPlayer.vue
Normal file
@@ -0,0 +1,867 @@
|
||||
<template>
|
||||
<n-drawer :show="show" height="100%" placement="bottom" :z-index="999999999" :to="`#layout-main`">
|
||||
<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 v-if="!isMobile" 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 { computed, 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);
|
||||
}
|
||||
unlockScreenOrientation();
|
||||
});
|
||||
|
||||
// 监听 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 lockScreenOrientation = async () => {
|
||||
try {
|
||||
if ('orientation' in screen) {
|
||||
await (screen as any).orientation.lock('landscape');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法锁定屏幕方向:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockScreenOrientation = () => {
|
||||
try {
|
||||
if ('orientation' in screen) {
|
||||
(screen as any).orientation.unlock();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法解锁屏幕方向:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 修改切换全屏状态的方法
|
||||
const toggleFullscreen = async () => {
|
||||
const api = checkFullscreenAPI();
|
||||
|
||||
if (!api.fullscreenEnabled) {
|
||||
console.warn('全屏API不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!api.fullscreenElement) {
|
||||
await videoContainerRef.value?.requestFullscreen();
|
||||
isFullscreen.value = true;
|
||||
// 在移动端进入全屏时锁定横屏
|
||||
if (window.innerWidth <= 768) {
|
||||
await lockScreenOrientation();
|
||||
}
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
isFullscreen.value = false;
|
||||
// 退出全屏时解锁屏幕方向
|
||||
if (window.innerWidth <= 768) {
|
||||
unlockScreenOrientation();
|
||||
}
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mv-detail {
|
||||
@apply w-full h-full bg-black relative;
|
||||
|
||||
// 添加横屏模式支持
|
||||
@media screen and (orientation: landscape) {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
@apply w-full h-full relative;
|
||||
transition: cursor 0.3s ease;
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.custom-controls {
|
||||
.controls-main {
|
||||
@apply flex-wrap gap-2 justify-center;
|
||||
|
||||
.left-controls,
|
||||
.right-controls {
|
||||
@apply w-full justify-center;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@apply order-first w-full text-center mb-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调整标题样式
|
||||
.mv-detail-title {
|
||||
.title {
|
||||
@apply text-base max-w-full;
|
||||
}
|
||||
}
|
||||
|
||||
// 调整进度条
|
||||
.progress-bar {
|
||||
@apply mb-2;
|
||||
}
|
||||
}
|
||||
|
||||
&.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: 100%;
|
||||
height: 100%;
|
||||
|
||||
// 确保全屏时标题栏正确显示
|
||||
.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>
|
||||
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
||||
<span
|
||||
v-show="isShowAllPlaylistCategory || index <= 19"
|
||||
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
|
||||
class="play-list-type-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
|
||||
:class="
|
||||
setAnimationClass(
|
||||
index <= 19
|
||||
? 'animate__bounceIn'
|
||||
: !isShowAllPlaylistCategory
|
||||
? 'animate__backOutLeft'
|
||||
: 'animate__bounceIn',
|
||||
) +
|
||||
' ' +
|
||||
'type-item-' +
|
||||
index
|
||||
"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="handleClickPlaylistType(item.name)"
|
||||
>{{ item.name }}</span
|
||||
>
|
||||
@@ -17,7 +28,7 @@
|
||||
class="play-list-type-showall"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30)"
|
||||
@click="isShowAllPlaylistCategory = !isShowAllPlaylistCategory"
|
||||
@click="handleToggleShowAllPlaylistCategory"
|
||||
>
|
||||
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
|
||||
</div>
|
||||
@@ -26,7 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
@@ -36,6 +47,59 @@ import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
// 是否显示全部歌单分类
|
||||
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 () => {
|
||||
@@ -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(() => {
|
||||
loadPlaylistCategory();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="recommend-album">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
|
||||
<div class="recommend-album-list">
|
||||
<template v-for="(item, index) in albumData?.albums" :key="item.id">
|
||||
<div
|
||||
@@ -20,7 +20,14 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<MusicList v-model:show="showMusic" :name="albumName" :song-list="songList" />
|
||||
<MusicList
|
||||
v-model:show="showMusic"
|
||||
:name="albumName"
|
||||
:song-list="songList"
|
||||
:cover="false"
|
||||
:loading="loadingList"
|
||||
:list-info="albumInfo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,15 +48,28 @@ const loadAlbumList = async () => {
|
||||
const showMusic = ref(false);
|
||||
const songList = ref([]);
|
||||
const albumName = ref('');
|
||||
|
||||
const loadingList = ref(false);
|
||||
const albumInfo = ref<any>({});
|
||||
const handleClick = async (item: any) => {
|
||||
songList.value = [];
|
||||
albumInfo.value = {};
|
||||
albumName.value = item.name;
|
||||
loadingList.value = true;
|
||||
showMusic.value = true;
|
||||
const res = await getAlbum(item.id);
|
||||
songList.value = res.data.songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || item.picUrl;
|
||||
return song;
|
||||
});
|
||||
albumInfo.value = {
|
||||
...res.data.album,
|
||||
creator: {
|
||||
avatarUrl: res.data.album.artist.img1v1Url,
|
||||
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`,
|
||||
},
|
||||
description: res.data.album.description,
|
||||
};
|
||||
loadingList.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="recommend-singer">
|
||||
<div class="recommend-singer-list">
|
||||
<div
|
||||
v-if="dayRecommendData"
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="setAnimationDelay(0, 100)"
|
||||
@@ -27,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in hotSingerData?.artists.slice(0, 4)"
|
||||
v-for="(item, index) in hotSingerData?.artists"
|
||||
:key="item.id"
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
@@ -52,6 +53,7 @@
|
||||
v-model:show="showMusic"
|
||||
name="每日推荐列表"
|
||||
:song-list="dayRecommendData?.dailySongs"
|
||||
:cover="false"
|
||||
/>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
@@ -59,6 +61,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getDayRecommend, getHotSinger } from '@/api/home';
|
||||
import router from '@/router';
|
||||
@@ -66,34 +69,41 @@ import { IDayRecommend } from '@/type/day_recommend';
|
||||
import type { IHotSinger } from '@/type/singer';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// 歌手信息
|
||||
const hotSingerData = ref<IHotSinger>();
|
||||
const dayRecommendData = ref<IDayRecommend>();
|
||||
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 () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [{ data: singerData }, { data: dayRecommend }] = await Promise.all([
|
||||
getHotSinger({ offset: 0, limit: 5 }),
|
||||
getDayRecommend(),
|
||||
]);
|
||||
// 第一个请求:获取热门歌手
|
||||
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
|
||||
|
||||
// 第二个请求:获取每日推荐
|
||||
try {
|
||||
const {
|
||||
data: { data: dayRecommend },
|
||||
} = await getDayRecommend();
|
||||
// 处理数据
|
||||
if (dayRecommend) {
|
||||
singerData.artists = singerData.artists.slice(0, 4);
|
||||
}
|
||||
dayRecommendData.value = dayRecommend;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
hotSingerData.value = singerData;
|
||||
dayRecommendData.value = dayRecommend.data;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toSearchSinger = (keyword: string) => {
|
||||
router.push({
|
||||
@@ -103,6 +113,13 @@ const toSearchSinger = (keyword: string) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 监听登录状态
|
||||
watchEffect(() => {
|
||||
if (store.state.user) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<div class="recommend-music">
|
||||
<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">
|
||||
<div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
|
||||
@@ -24,11 +29,14 @@ import SongItem from './common/SongItem.vue';
|
||||
const store = useStore();
|
||||
// 推荐歌曲
|
||||
const recommendMusic = ref<IRecommendMusic>();
|
||||
const loading = ref(false);
|
||||
|
||||
// 加载推荐歌曲
|
||||
const loadRecommendMusic = async () => {
|
||||
loading.value = true;
|
||||
const { data } = await getRecommendMusic({ limit: 10 });
|
||||
recommendMusic.value = data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
// 页面初始化
|
||||
|
||||
131
src/components/common/InstallAppModal.vue
Normal file
131
src/components/common/InstallAppModal.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<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 Player {{ config.version }}</h2>
|
||||
<p class="app-desc mb-2">在桌面安装应用,获得更好的体验</p>
|
||||
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
|
||||
</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 class="modal-desc mt-4 text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
下载遇到问题?去
|
||||
<a class="text-green-500" target="_blank" href="https://github.com/algerkong/AlgerMusicPlayer/releases"
|
||||
>GitHub</a
|
||||
>
|
||||
下载最新版本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import config from '@/../package.json';
|
||||
import { isMobile } from '@/utils';
|
||||
|
||||
const showModal = ref(false);
|
||||
const isElectron = ref((window as any).electron !== undefined);
|
||||
const noPrompt = ref(false);
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
if (noPrompt.value) {
|
||||
localStorage.setItem('installPromptDismissed', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 如果是 electron 环境,不显示安装提示
|
||||
if (isElectron.value || isMobile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经点击过"暂不安装"
|
||||
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
|
||||
if (isDismissed) {
|
||||
return;
|
||||
}
|
||||
showModal.value = true;
|
||||
});
|
||||
|
||||
const handleInstall = async (): Promise<void> => {
|
||||
const { userAgent } = navigator;
|
||||
console.log('userAgent', userAgent);
|
||||
const isMac: boolean = userAgent.includes('Mac');
|
||||
const isWindows: boolean = userAgent.includes('Win');
|
||||
const isARM: boolean = userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
|
||||
const isX64: boolean = userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
|
||||
const isX86: boolean =
|
||||
!isX64 && (userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
|
||||
|
||||
const getDownloadUrl = (os: string, arch: string): string => {
|
||||
const version = config.version as string;
|
||||
const setup = os !== 'mac' ? 'Setup_' : '';
|
||||
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
|
||||
};
|
||||
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
|
||||
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
|
||||
|
||||
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
|
||||
|
||||
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.install-app-modal {
|
||||
:deep(.n-modal) {
|
||||
@apply max-w-sm;
|
||||
}
|
||||
.modal-content {
|
||||
@apply p-4 pb-0;
|
||||
.modal-header {
|
||||
@apply flex items-center mb-6;
|
||||
.app-icon {
|
||||
@apply w-20 h-20 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 mt-4;
|
||||
.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>
|
||||
@@ -15,6 +15,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { audioService } from '@/services/audioService';
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
title: string;
|
||||
@@ -29,6 +31,7 @@ watch(
|
||||
if (val) {
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,15 +16,19 @@
|
||||
v-model:show="showPop"
|
||||
:name="item.name"
|
||||
:song-list="songList"
|
||||
:list-info="listInfo"
|
||||
/>
|
||||
|
||||
<PlayVideo v-if="item.type === 'mv'" v-model:show="showPop" :title="item.name" :url="url" />
|
||||
<mv-player v-if="item.type === 'mv'" v-model:show="showPop" :current-mv="getCurrentMv()" no-list />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getAlbum, getListDetail } from '@/api/list';
|
||||
import { getMvUrl } from '@/api/mv';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -37,13 +41,22 @@ const props = defineProps<{
|
||||
};
|
||||
}>();
|
||||
|
||||
const url = ref('');
|
||||
|
||||
const songList = ref<any[]>([]);
|
||||
|
||||
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 () => {
|
||||
listInfo.value = null;
|
||||
if (props.item.type === '专辑') {
|
||||
showPop.value = true;
|
||||
const res = await getAlbum(props.item.id);
|
||||
@@ -57,11 +70,13 @@ const handleClick = async () => {
|
||||
showPop.value = true;
|
||||
const res = await getListDetail(props.item.id);
|
||||
songList.value = res.data.playlist.tracks;
|
||||
listInfo.value = res.data.playlist;
|
||||
}
|
||||
|
||||
if (props.item.type === 'mv') {
|
||||
const res = await getMvUrl(props.item.id);
|
||||
url.value = res.data.data.url;
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showPop.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
<template>
|
||||
<div class="song-item" :class="{ 'song-mini': mini }">
|
||||
<n-image v-if="item.picUrl" :src="getImgUrl(item.picUrl, '40y40')" class="song-item-img" lazy preview-disabled />
|
||||
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
|
||||
<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-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.song.artists" :key="artistsindex"
|
||||
>{{ artists.name }}{{ artistsindex < item.song.artists.length - 1 ? ' / ' : '' }}</span
|
||||
<div v-if="list" class="song-item-content-wrapper">
|
||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||
<div class="song-item-content-divider">-</div>
|
||||
<n-ellipsis class="song-item-content-name 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 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 class="song-item-operating">
|
||||
<div class="song-item-operating-like">
|
||||
<i class="iconfont icon-likefill"></i>
|
||||
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
|
||||
<div v-if="favorite" class="song-item-operating-like">
|
||||
<i class="iconfont icon-likefill" :class="{ 'like-active': isFavorite }" @click.stop="toggleFavorite"></i>
|
||||
</div>
|
||||
<div
|
||||
class="song-item-operating-play bg-black animate__animated"
|
||||
@@ -30,18 +51,25 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { audioService } from '@/services/audioService';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageBackground } from '@/utils/linearColor';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: SongResult;
|
||||
mini?: boolean;
|
||||
list?: boolean;
|
||||
favorite?: boolean;
|
||||
}>(),
|
||||
{
|
||||
mini: false,
|
||||
list: false,
|
||||
favorite: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,15 +88,50 @@ const isPlaying = computed(() => {
|
||||
|
||||
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) => {
|
||||
if (playMusic.value.id === item.id) {
|
||||
if (play.value) {
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await store.commit('setPlay', item);
|
||||
store.commit('setIsPlay', true);
|
||||
emits('play', item);
|
||||
};
|
||||
|
||||
// 判断是否已收藏
|
||||
const isFavorite = computed(() => {
|
||||
return store.state.favoriteList.includes(props.item.id);
|
||||
});
|
||||
|
||||
// 切换收藏状态
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', props.item.id);
|
||||
} else {
|
||||
store.commit('addToFavorite', props.item.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -96,7 +159,7 @@ const playMusicEvent = async (item: SongResult) => {
|
||||
}
|
||||
}
|
||||
&-operating {
|
||||
@apply flex items-center pl-4 rounded-full border border-gray-700 ml-4;
|
||||
@apply flex items-center rounded-full border border-gray-700 ml-4;
|
||||
background-color: #0d0d0d;
|
||||
.iconfont {
|
||||
@apply text-xl;
|
||||
@@ -106,7 +169,10 @@ const playMusicEvent = async (item: SongResult) => {
|
||||
@apply text-xl hover:text-red-600 transition;
|
||||
}
|
||||
&-like {
|
||||
@apply mr-2 cursor-pointer;
|
||||
@apply mr-2 cursor-pointer ml-4;
|
||||
}
|
||||
.like-active {
|
||||
@apply text-red-600;
|
||||
}
|
||||
&-play {
|
||||
@apply cursor-pointer border border-gray-500 rounded-full w-10 h-10 flex justify-center items-center hover:bg-green-600 transition;
|
||||
@@ -137,7 +203,7 @@ const playMusicEvent = async (item: SongResult) => {
|
||||
@apply text-base;
|
||||
}
|
||||
&-like {
|
||||
@apply mr-1;
|
||||
@apply mr-1 ml-1;
|
||||
}
|
||||
&-play {
|
||||
@apply w-8 h-8;
|
||||
@@ -145,4 +211,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>
|
||||
|
||||
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,343 @@
|
||||
import { getMusicLrc } from '@/api/music';
|
||||
import { ILyric } from '@/type/lyric';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface ILrcData {
|
||||
text: string;
|
||||
trText: string;
|
||||
}
|
||||
import { audioService } from '@/services/audioService';
|
||||
import store from '@/store';
|
||||
import type { ILyricText, SongResult } from '@/type/music';
|
||||
|
||||
export const lrcData = ref<ILyric>();
|
||||
export const newLrcIndex = ref<number>(0);
|
||||
export const lrcArray = ref<Array<ILrcData>>([]);
|
||||
export const lrcTimeArray = ref<Array<Number>>([]);
|
||||
const windowData = window as any;
|
||||
|
||||
export const parseTime = (timeString: string) => {
|
||||
const [minutes, seconds] = timeString.split(':');
|
||||
return Number(minutes) * 60 + Number(seconds);
|
||||
};
|
||||
export const isElectron = computed(() => !!windowData.electronAPI);
|
||||
|
||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
||||
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
|
||||
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 playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
|
||||
export const sound = ref<Howl | null>(audioService.getCurrentSound());
|
||||
export const isLyricWindowOpen = ref(false); // 新增状态
|
||||
|
||||
function parseLyricLine(lyricLine: string) {
|
||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
||||
const time = parseTime(timeText);
|
||||
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
||||
return { time, text };
|
||||
}
|
||||
document.onkeyup = (e) => {
|
||||
// 检查事件目标是否是输入框元素
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
interface ILyricText {
|
||||
text: string;
|
||||
trText: string;
|
||||
}
|
||||
|
||||
function parseLyrics(lyricsString: string) {
|
||||
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);
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
if (store.state.play) {
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
// 歌词矫正时间Correction time
|
||||
const correctionTime = ref(0.4);
|
||||
watch(
|
||||
() => store.state.playMusicUrl,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
audioService.play(newVal);
|
||||
sound.value = audioService.getCurrentSound();
|
||||
audioServiceOn(audioService);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.state.playMusic,
|
||||
() => {
|
||||
nextTick(async () => {
|
||||
lrcArray.value = playMusic.value.lyric?.lrcArray || [];
|
||||
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
|
||||
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
||||
if (isElectron.value && isLyricWindowOpen.value && lrcArray.value.length > 0) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const audioServiceOn = (audio: typeof audioService) => {
|
||||
let interval: any = null;
|
||||
|
||||
// 监听播放
|
||||
audio.onPlay(() => {
|
||||
store.commit('setPlayMusic', true);
|
||||
interval = setInterval(() => {
|
||||
nowTime.value = sound.value?.seek() as number;
|
||||
allTime.value = sound.value?.duration() as number;
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
nowIndex.value = newIndex;
|
||||
currentLrcProgress.value = 0;
|
||||
// 当歌词索引更新时,发送歌词数据
|
||||
if (isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
// 定期发送歌词数据更新
|
||||
if (isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// 监听暂停
|
||||
audio.onPause(() => {
|
||||
store.commit('setPlayMusic', false);
|
||||
clearInterval(interval);
|
||||
// 暂停时也发送一次状态更新
|
||||
if (isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听结束
|
||||
audio.onEnd(() => {
|
||||
if (store.state.playMode === 1) {
|
||||
// 单曲循环模式
|
||||
audio.getCurrentSound()?.play();
|
||||
} else {
|
||||
// 列表循环模式
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const play = () => {
|
||||
audioService.getCurrentSound()?.play();
|
||||
};
|
||||
|
||||
export const pause = () => {
|
||||
audioService.getCurrentSound()?.pause();
|
||||
};
|
||||
|
||||
const isPlaying = computed(() => store.state.play as boolean);
|
||||
|
||||
// 增加矫正时间
|
||||
export const addCorrectionTime = (time: number) => {
|
||||
correctionTime.value += time;
|
||||
};
|
||||
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
|
||||
|
||||
// 减少矫正时间
|
||||
export const reduceCorrectionTime = (time: number) => {
|
||||
correctionTime.value -= time;
|
||||
};
|
||||
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
|
||||
|
||||
export const isCurrentLrc = (index: number, time: number) => {
|
||||
const currentTime = Number(lrcTimeArray.value[index]);
|
||||
const nextTime = Number(lrcTimeArray.value[index + 1]);
|
||||
// 获取当前播放歌词
|
||||
export const isCurrentLrc = (index: number, time: number): boolean => {
|
||||
const currentTime = lrcTimeArray.value[index];
|
||||
const nextTime = lrcTimeArray.value[index + 1];
|
||||
const nowTime = time + correctionTime.value;
|
||||
const isTrue = nowTime > currentTime && nowTime < nextTime;
|
||||
if (isTrue) {
|
||||
newLrcIndex.value = index;
|
||||
}
|
||||
return isTrue;
|
||||
};
|
||||
|
||||
export const nowTime = ref(0);
|
||||
export const allTime = ref(0);
|
||||
export const nowIndex = ref(0);
|
||||
|
||||
export const getLrcIndex = (time: number) => {
|
||||
// 获取当前播放歌词INDEX
|
||||
export const getLrcIndex = (time: number): number => {
|
||||
for (let i = 0; i < lrcTimeArray.value.length; i++) {
|
||||
if (isCurrentLrc(i, time)) {
|
||||
nowIndex.value = i || nowIndex.value;
|
||||
nowIndex.value = i;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return nowIndex.value;
|
||||
};
|
||||
|
||||
// 设置当前播放时间
|
||||
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
|
||||
audio.currentTime = lrcTimeArray.value[index] as number;
|
||||
audio.play();
|
||||
// 获取当前播放歌词进度
|
||||
const currentLrcTiming = computed(() => {
|
||||
const start = lrcTimeArray.value[nowIndex.value] || 0;
|
||||
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 {};
|
||||
};
|
||||
|
||||
// 计算这个歌词的播放时间
|
||||
const getLrcTime = (index: number) => {
|
||||
return Number(lrcTimeArray.value[index]);
|
||||
// 播放进度
|
||||
export const useLyricProgress = () => {
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
const updateProgress = () => {
|
||||
if (!isPlaying.value) return;
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound) return;
|
||||
|
||||
const { start, end } = currentLrcTiming.value;
|
||||
const duration = end - start;
|
||||
const elapsed = (currentSound.seek() as number) - 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,
|
||||
};
|
||||
};
|
||||
|
||||
// 设置<E8AEBE><E7BDAE><EFBFBD>前播放时间
|
||||
export const setAudioTime = (index: number) => {
|
||||
const currentSound = sound.value;
|
||||
if (!currentSound) return;
|
||||
|
||||
currentSound.seek(lrcTimeArray.value[index]);
|
||||
currentSound.play();
|
||||
};
|
||||
|
||||
// 获取当前播放的歌词
|
||||
export const getCurrentLrc = () => {
|
||||
const index = getLrcIndex(nowTime.value);
|
||||
const currentLrc = lrcArray.value[index];
|
||||
const nextLrc = lrcArray.value[index + 1];
|
||||
return { currentLrc, nextLrc };
|
||||
return {
|
||||
currentLrc: lrcArray.value[index],
|
||||
nextLrc: lrcArray.value[index + 1],
|
||||
};
|
||||
};
|
||||
|
||||
// 获取一句歌词播放时间是 几秒到几秒
|
||||
export const getLrcTimeRange = (index: number) => {
|
||||
const currentTime = Number(lrcTimeArray.value[index]);
|
||||
const nextTime = Number(lrcTimeArray.value[index + 1]);
|
||||
return { currentTime, nextTime };
|
||||
};
|
||||
// 获取一句歌词播放时间几秒到几秒
|
||||
export const getLrcTimeRange = (index: number) => ({
|
||||
currentTime: lrcTimeArray.value[index],
|
||||
nextTime: lrcTimeArray.value[index + 1],
|
||||
});
|
||||
|
||||
// 监听歌词数组变化,当切换歌曲时重新初始化歌词窗口
|
||||
watch(
|
||||
() => lrcArray.value,
|
||||
(newLrcArray) => {
|
||||
if (newLrcArray.length > 0 && isElectron.value && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 发送歌词更新数据
|
||||
export const sendLyricToWin = () => {
|
||||
if (!isElectron.value || !isLyricWindowOpen.value) {
|
||||
console.log('Cannot send lyric: electron or lyric window not available');
|
||||
return;
|
||||
}
|
||||
|
||||
export const sendLyricToWin = (isPlay: boolean = true) => {
|
||||
try {
|
||||
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
|
||||
let lyricWinData = null;
|
||||
if (lrcArray.value.length > 0) {
|
||||
const nowIndex = getLrcIndex(nowTime.value);
|
||||
const { currentLrc, nextLrc } = getCurrentLrc();
|
||||
const { currentTime, nextTime } = getLrcTimeRange(nowIndex);
|
||||
lyricWinData = {
|
||||
currentLrc,
|
||||
nextLrc,
|
||||
currentTime,
|
||||
nextTime,
|
||||
const updateData = {
|
||||
type: 'full',
|
||||
nowIndex,
|
||||
lrcTimeArray: lrcTimeArray.value,
|
||||
lrcArray: lrcArray.value,
|
||||
nowTime: nowTime.value,
|
||||
startCurrentTime: lrcTimeArray.value[nowIndex],
|
||||
nextTime: lrcTimeArray.value[nowIndex + 1],
|
||||
isPlay: isPlaying.value,
|
||||
lrcArray: lrcArray.value,
|
||||
lrcTimeArray: lrcTimeArray.value,
|
||||
allTime: allTime.value,
|
||||
startCurrentTime: getLrcTime(nowIndex),
|
||||
isPlay,
|
||||
playMusic: playMusic.value,
|
||||
};
|
||||
|
||||
const windowData = window as any;
|
||||
windowData.electronAPI.sendLyric(JSON.stringify(lyricWinData));
|
||||
windowData.electronAPI.sendLyric(JSON.stringify(updateData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
console.error('Error sending lyric update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const openLyric = () => {
|
||||
const windowData = window as any;
|
||||
windowData.electronAPI.openLyric();
|
||||
sendLyricToWin();
|
||||
if (!isElectron.value) return;
|
||||
console.log('Opening lyric window with current song:', playMusic.value?.name);
|
||||
|
||||
isLyricWindowOpen.value = !isLyricWindowOpen.value;
|
||||
if (isLyricWindowOpen.value) {
|
||||
setTimeout(() => {
|
||||
windowData.electronAPI.openLyric();
|
||||
sendLyricToWin();
|
||||
}, 500);
|
||||
sendLyricToWin();
|
||||
} else {
|
||||
closeLyric();
|
||||
}
|
||||
};
|
||||
|
||||
// 添加关闭歌词窗口的方法
|
||||
export const closeLyric = () => {
|
||||
if (!isElectron.value) return;
|
||||
windowData.electron.ipcRenderer.send('close-lyric');
|
||||
};
|
||||
|
||||
// 添加播放控制命令监听
|
||||
if (isElectron.value) {
|
||||
windowData.electron.ipcRenderer.on('lyric-control-back', (command: string) => {
|
||||
console.log('Received playback control command:', command);
|
||||
switch (command) {
|
||||
case 'playpause':
|
||||
if (store.state.play) {
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
break;
|
||||
case 'prev':
|
||||
store.commit('prevPlay');
|
||||
break;
|
||||
case 'next':
|
||||
store.commit('nextPlay');
|
||||
break;
|
||||
case 'close':
|
||||
closeLyric();
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown command:', command);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
191
src/hooks/MusicListHook.ts
Normal file
191
src/hooks/MusicListHook.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
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 sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false,
|
||||
});
|
||||
return sound;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
audioService.getCurrentSound()?.play();
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
audioService.getCurrentSound()?.pause();
|
||||
};
|
||||
|
||||
return {
|
||||
handlePlayMusic,
|
||||
nextPlay,
|
||||
prevPlay,
|
||||
play,
|
||||
pause,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.n-slider-handle-indicator--top {
|
||||
@apply bg-transparent text-[#ffffffdd] text-2xl px-2 py-1 shadow-none mb-0 !important;
|
||||
}
|
||||
|
||||
.text-el {
|
||||
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="layout-page">
|
||||
<div class="layout-main">
|
||||
<div id="layout-main" class="layout-main" :style="{ background: backgroundColor }">
|
||||
<title-bar v-if="isElectron" />
|
||||
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
|
||||
<!-- 侧边菜单栏 -->
|
||||
@@ -9,18 +9,16 @@
|
||||
<!-- 搜索栏 -->
|
||||
<search-bar />
|
||||
<!-- 主页面路由 -->
|
||||
<div class="main-content bg-black" :native-scrollbar="false">
|
||||
<n-message-provider>
|
||||
<router-view
|
||||
v-slot="{ Component }"
|
||||
class="main-page"
|
||||
:class="route.meta.noScroll && !isMobile ? 'pr-3' : ''"
|
||||
>
|
||||
<keep-alive :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</n-message-provider>
|
||||
<div class="main-content" :native-scrollbar="false">
|
||||
<router-view
|
||||
v-slot="{ Component }"
|
||||
class="main-page"
|
||||
:class="route.meta.noScroll && !isMobile ? 'pr-3' : ''"
|
||||
>
|
||||
<keep-alive :include="keepAliveInclude">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
<play-bottom height="5rem" />
|
||||
<app-menu v-if="isMobile" class="menu" :menus="menus" />
|
||||
@@ -29,6 +27,7 @@
|
||||
<!-- 底部音乐播放 -->
|
||||
<play-bar v-if="isPlay" />
|
||||
</div>
|
||||
<install-app-modal></install-app-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +35,9 @@
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import InstallAppModal from '@/components/common/InstallAppModal.vue';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
import homeRouter from '@/router/home';
|
||||
import { isMobile } from '@/utils';
|
||||
|
||||
@@ -61,80 +62,20 @@ const store = useStore();
|
||||
|
||||
const isPlay = computed(() => store.state.isPlay as boolean);
|
||||
const { menus } = store.state;
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const audio = {
|
||||
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
|
||||
};
|
||||
|
||||
const windowData = window as any;
|
||||
const isElectron = computed(() => {
|
||||
return !!windowData.electronAPI;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 监听音乐是否播放
|
||||
watch(
|
||||
() => play.value,
|
||||
(value) => {
|
||||
if (value && audio.value) {
|
||||
audioPlay();
|
||||
} else {
|
||||
audioPause();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
document.onkeyup = (e) => {
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
playMusicEvent();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
// 按下键盘按钮监听
|
||||
document.onkeydown = (e) => {
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
return false;
|
||||
default:
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const audioPlay = () => {
|
||||
if (audio.value) {
|
||||
audio.value.play();
|
||||
}
|
||||
};
|
||||
|
||||
const audioPause = () => {
|
||||
if (audio.value) {
|
||||
audio.value.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const playMusicEvent = async () => {
|
||||
if (play.value) {
|
||||
store.commit('setPlayMusic', false);
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
}
|
||||
};
|
||||
const backgroundColor = ref('#000');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@apply flex justify-center items-center overflow-hidden;
|
||||
@apply flex justify-center items-center overflow-hidden bg-black;
|
||||
}
|
||||
|
||||
.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%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,100 +1,193 @@
|
||||
<template>
|
||||
<n-drawer :show="musicFull" height="100vh" placement="bottom" :drawer-style="{ backgroundColor: 'transparent' }">
|
||||
<n-drawer
|
||||
:show="musicFull"
|
||||
height="100%"
|
||||
placement="bottom"
|
||||
:style="{ background: currentBackground || background }"
|
||||
:to="`#layout-main`"
|
||||
>
|
||||
<div id="drawer-target">
|
||||
<div
|
||||
class="drawer-back"
|
||||
:class="{ paused: !isPlaying }"
|
||||
:style="{ backgroundImage: `url(${getImgUrl(playMusic?.picUrl, '300y300')})` }"
|
||||
></div>
|
||||
<div class="drawer-back"></div>
|
||||
<div class="music-img">
|
||||
<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 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
|
||||
ref="lrcSider"
|
||||
class="music-lrc"
|
||||
style="height: 55vh"
|
||||
style="height: 60vh"
|
||||
:native-scrollbar="false"
|
||||
@mouseover="mouseOverLayout"
|
||||
@mouseleave="mouseLeaveLayout"
|
||||
>
|
||||
<template v-for="(item, index) in lrcArray" :key="index">
|
||||
<div ref="lrcContainer">
|
||||
<div
|
||||
v-for="(item, index) in lrcArray"
|
||||
:id="`music-lrc-text-${index}`"
|
||||
:key="index"
|
||||
class="music-lrc-text"
|
||||
:class="{ 'now-text': isCurrentLrc(index, nowTime) }"
|
||||
@click="setAudioTime(index, audio)"
|
||||
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||
@click="setAudioTime(index)"
|
||||
>
|
||||
<div>{{ item.text }}</div>
|
||||
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||
<div class="music-lrc-text-tr">{{ item.trText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
<!-- 时间矫正 -->
|
||||
<div class="music-content-time">
|
||||
<!-- <div class="music-content-time">
|
||||
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
|
||||
<n-button @click="addCorrectionTime(0.2)">+</n-button>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
addCorrectionTime,
|
||||
isCurrentLrc,
|
||||
lrcArray,
|
||||
newLrcIndex,
|
||||
nowTime,
|
||||
reduceCorrectionTime,
|
||||
setAudioTime,
|
||||
} from '@/hooks/MusicHook';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { lrcArray, nowIndex, playMusic, setAudioTime, useLyricProgress } from '@/hooks/MusicHook';
|
||||
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({
|
||||
musicFull: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
audio: {
|
||||
type: HTMLAudioElement,
|
||||
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 = () => {
|
||||
if (props.musicFull && !isMouse.value) {
|
||||
const top = newLrcIndex.value * 60 - 225;
|
||||
lrcSider.value.scrollTo({ top, behavior: 'smooth' });
|
||||
const lrcScroll = (behavior = 'smooth') => {
|
||||
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
|
||||
if (props.musicFull && !isMouse.value && nowEl && lrcContainer.value) {
|
||||
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 = () => {
|
||||
isMouse.value = true;
|
||||
};
|
||||
const mouseLeaveLayout = () => {
|
||||
setTimeout(() => {
|
||||
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({
|
||||
lrcScroll,
|
||||
});
|
||||
@@ -110,14 +203,12 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
.drawer-back {
|
||||
@apply absolute bg-cover bg-center opacity-70;
|
||||
filter: blur(80px) brightness(80%);
|
||||
@apply absolute bg-cover bg-center;
|
||||
z-index: -1;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
animation: round 20s linear infinite;
|
||||
}
|
||||
|
||||
.drawer-back.paused {
|
||||
@@ -125,30 +216,27 @@ defineExpose({
|
||||
}
|
||||
|
||||
#drawer-target {
|
||||
@apply top-0 left-0 absolute w-full h-full overflow-hidden rounded px-24 pt-24 pb-48 flex items-center;
|
||||
backdrop-filter: blur(20px);
|
||||
background-color: rgba(0, 0, 0, 0.747);
|
||||
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
|
||||
animation-duration: 300ms;
|
||||
|
||||
.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 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
@apply rounded-xl;
|
||||
@apply rounded-xl w-full h-full shadow-2xl;
|
||||
}
|
||||
}
|
||||
|
||||
.music-content {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
@apply flex flex-col justify-center items-center relative;
|
||||
|
||||
&-name {
|
||||
@apply font-bold text-3xl py-2;
|
||||
@apply font-bold text-xl pb-1 pt-4;
|
||||
}
|
||||
|
||||
&-singer {
|
||||
@apply text-base py-2;
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,25 +244,37 @@ defineExpose({
|
||||
display: none;
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
|
||||
.music-lrc {
|
||||
background-color: inherit;
|
||||
width: 500px;
|
||||
height: 550px;
|
||||
.now-text {
|
||||
@apply text-red-500;
|
||||
}
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||
&-text {
|
||||
@apply text-white text-lg flex flex-col justify-center items-center cursor-pointer font-bold;
|
||||
height: 60px;
|
||||
transition: all 0.2s ease-out;
|
||||
@apply text-2xl cursor-pointer font-bold px-2 py-4;
|
||||
transition: all 0.3s ease;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
@apply font-bold text-red-500;
|
||||
span {
|
||||
background-clip: text !important;
|
||||
-webkit-background-clip: text !important;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
&-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,13 +282,24 @@ defineExpose({
|
||||
|
||||
.mobile {
|
||||
#drawer-target {
|
||||
@apply flex-col p-4 pt-8;
|
||||
@apply flex-col p-4 pt-8 justify-start;
|
||||
.music-img {
|
||||
display: none;
|
||||
}
|
||||
.music-lrc {
|
||||
height: calc(100vh - 260px) !important;
|
||||
width: 100vw;
|
||||
span {
|
||||
padding-right: 0px !important;
|
||||
}
|
||||
}
|
||||
.music-lrc-text {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-drawer {
|
||||
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
<template>
|
||||
<!-- 展开全屏 -->
|
||||
<music-full ref="MusicFullRef" v-model:musicFull="musicFull" :audio="audio.value as HTMLAudioElement" />
|
||||
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
|
||||
<!-- 底部播放栏 -->
|
||||
<div class="music-play-bar" :class="setAnimationClass('animate__bounceInUp')">
|
||||
<n-image
|
||||
:src="getImgUrl(playMusic?.picUrl, '300y300')"
|
||||
class="play-bar-img"
|
||||
lazy
|
||||
preview-disabled
|
||||
@click="setMusicFull"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="music-play-bar"
|
||||
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
|
||||
>
|
||||
<div class="music-time custom-slider">
|
||||
<n-slider v-model:value="timeSlider" :step="1" :max="allTime" :min="0" :format-tooltip="formatTooltip"></n-slider>
|
||||
</div>
|
||||
<div class="play-bar-img-wrapper" @click="setMusicFull">
|
||||
<n-image :src="getImgUrl(playMusic?.picUrl, '300y300')" class="play-bar-img" lazy preview-disabled />
|
||||
<div class="hover-arrow">
|
||||
<div class="hover-content">
|
||||
<!-- <i class="ri-arrow-up-s-line text-3xl" :class="{ 'ri-arrow-down-s-line': musicFullVisible }"></i> -->
|
||||
<i class="text-3xl" :class="musicFullVisible ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'"></i>
|
||||
<span class="hover-text">{{ musicFullVisible ? '收起' : '展开' }}歌词</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-content">
|
||||
<div class="music-content-title">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
@@ -18,9 +28,10 @@
|
||||
</div>
|
||||
<div class="music-content-name">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
<span v-for="(item, index) in playMusic.song.artists" :key="index">
|
||||
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
<span v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists" :key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{ artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,41 +42,51 @@
|
||||
<div class="music-buttons-play" @click="playMusicEvent">
|
||||
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
|
||||
</div>
|
||||
<div class="music-buttons-next" @click="handleEnded">
|
||||
<div class="music-buttons-next" @click="handleNext">
|
||||
<i class="iconfont icon-next"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-time">
|
||||
<div class="time">{{ getNowTime }}</div>
|
||||
<n-slider v-model:value="timeSlider" :step="0.05" :tooltip="false"></n-slider>
|
||||
<div class="time">{{ getAllTime }}</div>
|
||||
</div>
|
||||
<div class="audio-volume">
|
||||
<div>
|
||||
<i class="iconfont icon-notificationfill"></i>
|
||||
</div>
|
||||
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false"></n-slider>
|
||||
</div>
|
||||
<div class="audio-button">
|
||||
<!-- <n-tooltip trigger="hover" :z-index="9999999">
|
||||
<div class="audio-volume custom-slider">
|
||||
<div class="volume-icon" @click="mute">
|
||||
<i class="iconfont" :class="getVolumeIcon"></i>
|
||||
</div>
|
||||
<div class="volume-slider">
|
||||
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
|
||||
</div>
|
||||
</div>
|
||||
<n-tooltip trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-likefill"></i>
|
||||
<i class="iconfont" :class="playModeIcon" @click="togglePlayMode"></i>
|
||||
</template>
|
||||
{{ playModeText }}
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-likefill" :class="{ 'like-active': isFavorite }" @click="toggleFavorite"></i>
|
||||
</template>
|
||||
喜欢
|
||||
</n-tooltip> -->
|
||||
<!-- <n-tooltip trigger="hover" :z-index="9999999">
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont icon-Play" @click="parsingMusic"></i>
|
||||
</template>
|
||||
解析播放
|
||||
</n-tooltip> -->
|
||||
<n-tooltip class="music-lyric" trigger="hover" :z-index="9999999">
|
||||
<template #trigger>
|
||||
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i>
|
||||
<i
|
||||
class="iconfont ri-netease-cloud-music-line"
|
||||
:class="{ 'text-green-500': isLyricWindowOpen }"
|
||||
@click="openLyricWindow"
|
||||
></i>
|
||||
</template>
|
||||
歌词
|
||||
</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>
|
||||
<n-tooltip trigger="manual" :z-index="9999999">
|
||||
<template #trigger>
|
||||
@@ -76,11 +97,13 @@
|
||||
</template>
|
||||
<div class="music-play-list">
|
||||
<div class="music-play-list-back"></div>
|
||||
<n-scrollbar>
|
||||
<div class="music-play-list-content">
|
||||
<song-item v-for="item in playList" :key="item.id" :item="item" mini></song-item>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
|
||||
<template #default="{ item }">
|
||||
<div class="music-play-list-content">
|
||||
<song-item :key="item.id" :item="item" mini></song-item>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</n-popover>
|
||||
</div>
|
||||
@@ -89,10 +112,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { allTime, loadLrc, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
|
||||
import { allTime, isElectron, isLyricWindowOpen, nowTime, openLyric, sound } from '@/hooks/MusicHook';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
|
||||
|
||||
@@ -104,77 +129,82 @@ const store = useStore();
|
||||
const playMusic = computed(() => store.state.playMusic as SongResult);
|
||||
// 是否播放
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
|
||||
const playList = computed(() => store.state.playList as SongResult[]);
|
||||
|
||||
const audio = {
|
||||
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
|
||||
};
|
||||
const background = ref('#000');
|
||||
|
||||
watch(
|
||||
() => store.state.playMusicUrl,
|
||||
() => {
|
||||
loadLrc(playMusic.value.id);
|
||||
() => store.state.playMusic,
|
||||
async () => {
|
||||
background.value = playMusic.value.backgroundColor as string;
|
||||
},
|
||||
{ immediate: true },
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const audioPlay = () => {
|
||||
if (audio.value) {
|
||||
audio.value.play();
|
||||
}
|
||||
};
|
||||
// 使用 useThrottleFn 创建节流版本的 seek 函数
|
||||
const throttledSeek = useThrottleFn((value: number) => {
|
||||
if (!sound.value) return;
|
||||
sound.value.seek(value);
|
||||
nowTime.value = value;
|
||||
}, 50); // 50ms 的节流延迟
|
||||
|
||||
// 计算属性 获取当前播放时间的进度
|
||||
// 修改 timeSlider 计算属性
|
||||
const timeSlider = computed({
|
||||
get: () => (nowTime.value / allTime.value) * 100,
|
||||
set: (value) => {
|
||||
if (!audio.value) return;
|
||||
audio.value.currentTime = (value * allTime.value) / 100;
|
||||
audioPlay();
|
||||
store.commit('setPlayMusic', true);
|
||||
},
|
||||
get: () => nowTime.value,
|
||||
set: throttledSeek,
|
||||
});
|
||||
|
||||
const formatTooltip = (value: number) => {
|
||||
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
|
||||
};
|
||||
|
||||
// 音量条
|
||||
const audioVolume = ref(1);
|
||||
const audioVolume = ref(localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1);
|
||||
const getVolumeIcon = computed(() => {
|
||||
// 0 静音 ri-volume-mute-line 0.5 ri-volume-down-line 1 ri-volume-up-line
|
||||
if (audioVolume.value === 0) {
|
||||
return 'ri-volume-mute-line';
|
||||
}
|
||||
if (audioVolume.value <= 0.5) {
|
||||
return 'ri-volume-down-line';
|
||||
}
|
||||
return 'ri-volume-up-line';
|
||||
});
|
||||
|
||||
const volumeSlider = computed({
|
||||
get: () => audioVolume.value * 100,
|
||||
set: (value) => {
|
||||
if (!audio.value) return;
|
||||
audio.value.volume = value / 100;
|
||||
if (!sound.value) return;
|
||||
localStorage.setItem('volume', (value / 100).toString());
|
||||
sound.value.volume(value / 100);
|
||||
audioVolume.value = value / 100;
|
||||
},
|
||||
});
|
||||
// 获取当前播放时间
|
||||
const getNowTime = computed(() => {
|
||||
return secondToMinute(nowTime.value);
|
||||
});
|
||||
|
||||
// 获取总时间
|
||||
const getAllTime = computed(() => {
|
||||
return secondToMinute(allTime.value);
|
||||
});
|
||||
|
||||
// 监听音乐播放 获取时间
|
||||
const onAudio = () => {
|
||||
if (audio.value) {
|
||||
audio.value.removeEventListener('timeupdate', handleGetAudioTime);
|
||||
audio.value.removeEventListener('ended', handleEnded);
|
||||
audio.value.addEventListener('timeupdate', handleGetAudioTime);
|
||||
audio.value.addEventListener('ended', handleEnded);
|
||||
// 监听音乐播放暂停
|
||||
audio.value.addEventListener('pause', () => {
|
||||
store.commit('setPlayMusic', false);
|
||||
});
|
||||
audio.value.addEventListener('play', () => {
|
||||
store.commit('setPlayMusic', true);
|
||||
});
|
||||
// 静音
|
||||
const mute = () => {
|
||||
if (volumeSlider.value === 0) {
|
||||
volumeSlider.value = 30;
|
||||
} else {
|
||||
volumeSlider.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
onAudio();
|
||||
// 播放模式
|
||||
const playMode = computed(() => store.state.playMode);
|
||||
const playModeIcon = computed(() => {
|
||||
return playMode.value === 0 ? 'ri-repeat-2-line' : 'ri-repeat-one-line';
|
||||
});
|
||||
const playModeText = computed(() => {
|
||||
return playMode.value === 0 ? '列表循环' : '单曲循环';
|
||||
});
|
||||
|
||||
function handleEnded() {
|
||||
// 切换播放模式
|
||||
const togglePlayMode = () => {
|
||||
store.commit('togglePlayMode');
|
||||
};
|
||||
|
||||
function handleNext() {
|
||||
store.commit('nextPlay');
|
||||
}
|
||||
|
||||
@@ -184,33 +214,52 @@ function handlePrev() {
|
||||
|
||||
const MusicFullRef = ref<any>(null);
|
||||
|
||||
function handleGetAudioTime(this: HTMLAudioElement) {
|
||||
// 监听音频播放的实时时间事件
|
||||
const audio = this as HTMLAudioElement;
|
||||
// 获取当前播放时间
|
||||
nowTime.value = Math.floor(audio.currentTime);
|
||||
// 获取总时间
|
||||
allTime.value = audio.duration;
|
||||
// 获取音量
|
||||
audioVolume.value = audio.volume;
|
||||
sendLyricToWin(store.state.isPlay);
|
||||
MusicFullRef.value?.lrcScroll();
|
||||
}
|
||||
|
||||
// 播放暂停按钮事件
|
||||
const playMusicEvent = async () => {
|
||||
if (play.value) {
|
||||
if (sound.value) {
|
||||
sound.value.pause();
|
||||
}
|
||||
store.commit('setPlayMusic', false);
|
||||
} else {
|
||||
if (sound.value) {
|
||||
sound.value.play();
|
||||
}
|
||||
store.commit('setPlayMusic', true);
|
||||
}
|
||||
};
|
||||
|
||||
const musicFull = ref(false);
|
||||
const musicFullVisible = ref(false);
|
||||
|
||||
// 设置musicFull
|
||||
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);
|
||||
};
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
return store.state.favoriteList.includes(playMusic.value.id);
|
||||
});
|
||||
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', playMusic.value.id);
|
||||
} else {
|
||||
store.commit('addToFavorite', playMusic.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openLyricWindow = () => {
|
||||
openLyric();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -220,13 +269,13 @@ const setMusicFull = () => {
|
||||
}
|
||||
|
||||
.music-play-bar {
|
||||
@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 box-border px-6 py-2 pt-3;
|
||||
z-index: 9999;
|
||||
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;
|
||||
.music-content {
|
||||
width: 140px;
|
||||
width: 160px;
|
||||
@apply ml-4;
|
||||
|
||||
&-title {
|
||||
@@ -234,25 +283,29 @@ const setMusicFull = () => {
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply text-xs mt-1;
|
||||
@apply text-gray-400;
|
||||
@apply text-xs mt-1 text-gray-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.play-bar-opcity {
|
||||
@apply bg-transparent;
|
||||
box-shadow: 0 0 20px 5px #0000001d;
|
||||
}
|
||||
|
||||
.play-bar-img {
|
||||
@apply w-14 h-14 rounded-2xl;
|
||||
}
|
||||
|
||||
.music-buttons {
|
||||
@apply mx-6;
|
||||
@apply mx-6 flex-1 flex justify-center;
|
||||
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-xl hover:text-white;
|
||||
@apply text-3xl hover:text-white;
|
||||
}
|
||||
|
||||
@apply flex items-center;
|
||||
@@ -262,25 +315,28 @@ const setMusicFull = () => {
|
||||
}
|
||||
|
||||
&-play {
|
||||
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
|
||||
background: #383838;
|
||||
}
|
||||
}
|
||||
|
||||
.music-time {
|
||||
@apply flex flex-1 items-center;
|
||||
|
||||
.time {
|
||||
@apply mx-4 mt-1;
|
||||
background-color: #ffffff20;
|
||||
@apply flex justify-center items-center w-20 h-12 rounded-full mx-4 hover:bg-[#ffffff40] transition;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-volume {
|
||||
width: 140px;
|
||||
@apply flex items-center mx-4;
|
||||
@apply flex items-center relative;
|
||||
&:hover {
|
||||
.volume-slider {
|
||||
@apply opacity-100 visible;
|
||||
}
|
||||
}
|
||||
.volume-icon {
|
||||
@apply cursor-pointer;
|
||||
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition cursor-pointer mr-4;
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
@apply absolute opacity-0 invisible transition-all duration-300 bottom-[30px] left-1/2 -translate-x-1/2 h-[180px] px-2 py-4 bg-gray-800 bg-opacity-80 rounded-xl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,13 +351,14 @@ const setMusicFull = () => {
|
||||
.music-play {
|
||||
&-list {
|
||||
height: 50vh;
|
||||
@apply relative rounded-3xl overflow-hidden;
|
||||
width: 300px;
|
||||
@apply relative rounded-3xl overflow-hidden py-2;
|
||||
&-back {
|
||||
backdrop-filter: blur(20px);
|
||||
@apply absolute top-0 left-0 w-full h-full bg-gray-800 bg-opacity-75;
|
||||
}
|
||||
&-content {
|
||||
padding: 10px;
|
||||
@apply mx-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,4 +394,103 @@ const setMusicFull = () => {
|
||||
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);
|
||||
|
||||
&.n-slider--vertical {
|
||||
height: 100%;
|
||||
|
||||
.n-slider-rail {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.n-slider-rail {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.n-slider-handle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.n-slider-rail {
|
||||
@apply overflow-hidden transition-all duration-200;
|
||||
}
|
||||
|
||||
.n-slider-handle {
|
||||
@apply transition-all duration-200;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .n-slider-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #18a058;
|
||||
}
|
||||
|
||||
.play-bar-img-wrapper {
|
||||
@apply relative cursor-pointer w-14 h-14;
|
||||
|
||||
.hover-arrow {
|
||||
@apply absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 rounded-2xl;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
.hover-content {
|
||||
@apply flex flex-col items-center justify-center;
|
||||
|
||||
i {
|
||||
@apply text-white mb-0.5;
|
||||
}
|
||||
|
||||
.hover-text {
|
||||
@apply text-white text-xs scale-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.hover-arrow {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
@apply text-sm py-1 px-2;
|
||||
}
|
||||
|
||||
.play-bar-img {
|
||||
@apply w-14 h-14 rounded-2xl;
|
||||
}
|
||||
|
||||
.like-active {
|
||||
@apply text-red-600;
|
||||
}
|
||||
|
||||
.icon-loop,
|
||||
.icon-single-loop {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.music-time .n-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,28 +13,57 @@
|
||||
<i class="iconfont icon-search"></i>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="w-20 px-3 flex justify-between items-center">
|
||||
<div>{{ searchTypeOptions.find((item) => item.key === searchType)?.label }}</div>
|
||||
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
||||
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
|
||||
<div class="w-20 px-3 flex justify-between items-center">
|
||||
<div>{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}</div>
|
||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
<div class="user-box">
|
||||
<n-dropdown trigger="hover" :options="userSetOptions" @select="selectItem">
|
||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||
</n-dropdown>
|
||||
<n-avatar
|
||||
v-if="store.state.user"
|
||||
class="ml-2 cursor-pointer"
|
||||
circle
|
||||
size="medium"
|
||||
:src="getImgUrl(store.state.user.avatarUrl)"
|
||||
/>
|
||||
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
|
||||
</div>
|
||||
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
|
||||
<template #trigger>
|
||||
<div class="user-box">
|
||||
<n-avatar
|
||||
v-if="store.state.user"
|
||||
class="ml-2 cursor-pointer"
|
||||
circle
|
||||
size="medium"
|
||||
:src="getImgUrl(store.state.user.avatarUrl)"
|
||||
@click="selectItem('user')"
|
||||
/>
|
||||
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="user-popover">
|
||||
<div v-if="store.state.user" class="user-header" @click="selectItem('user')">
|
||||
<n-avatar circle size="small" :src="getImgUrl(store.state.user?.avatarUrl)" />
|
||||
<span class="username">{{ store.state.user?.nickname || 'Theodore' }}</span>
|
||||
</div>
|
||||
<div class="menu-items">
|
||||
<div v-if="!store.state.user" class="menu-item" @click="toLogin">
|
||||
<i class="iconfont ri-login-box-line"></i>
|
||||
<span>去登录</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="selectItem('set')">
|
||||
<i class="iconfont ri-settings-3-line"></i>
|
||||
<span>设置</span>
|
||||
</div>
|
||||
<div class="menu-item" @click="toGithubRelease">
|
||||
<i class="iconfont ri-refresh-line"></i>
|
||||
<span>当前版本</span>
|
||||
<span class="download-btn">{{ config.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
|
||||
<coffee :alipay-q-r="alipay" :wechat-q-r="wechat">
|
||||
<div class="github" @click="toGithub">
|
||||
<i class="ri-github-fill"></i>
|
||||
</div>
|
||||
</coffee>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,8 +71,12 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import config from '@/../package.json';
|
||||
import { getSearchKeyword } from '@/api/home';
|
||||
import { getUserDetail, logout } from '@/api/login';
|
||||
import alipay from '@/assets/alipay.png';
|
||||
import wechat from '@/assets/wechat.png';
|
||||
import Coffee from '@/components/Coffee.vue';
|
||||
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
@@ -90,7 +123,6 @@ onMounted(() => {
|
||||
|
||||
// 搜索词
|
||||
const searchValue = ref('');
|
||||
const searchType = ref(1);
|
||||
const search = () => {
|
||||
const { value } = searchValue;
|
||||
if (value === '') {
|
||||
@@ -98,17 +130,21 @@ const search = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (router.currentRoute.value.path === '/search') {
|
||||
store.state.searchValue = value;
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: '/search',
|
||||
query: {
|
||||
keyword: value,
|
||||
type: searchType.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectSearchType = (key: number) => {
|
||||
searchType.value = key;
|
||||
store.state.searchType = key;
|
||||
};
|
||||
|
||||
const searchTypeOptions = ref(SEARCH_TYPES);
|
||||
@@ -129,14 +165,25 @@ const selectItem = async (key: string) => {
|
||||
case 'set':
|
||||
router.push('/set');
|
||||
break;
|
||||
case 'user':
|
||||
router.push('/user');
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const toGithub = () => {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank');
|
||||
};
|
||||
|
||||
const toGithubRelease = () => {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-box {
|
||||
@apply ml-4 flex text-lg justify-center items-center rounded-full pl-3 border border-gray-600;
|
||||
@apply ml-4 flex text-lg justify-center items-center rounded-full border border-gray-600 hover:border-gray-400 transition-colors duration-200;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
.search-box {
|
||||
@@ -151,4 +198,69 @@ const selectItem = async (key: string) => {
|
||||
@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 h-full;
|
||||
}
|
||||
|
||||
.user-popover {
|
||||
@apply min-w-[280px] p-0 rounded-xl overflow-hidden;
|
||||
background: #2c2c2c;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.user-header {
|
||||
@apply flex items-center gap-2 p-3;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
|
||||
.username {
|
||||
@apply text-sm font-medium text-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
@apply py-1;
|
||||
|
||||
.menu-item {
|
||||
@apply flex items-center px-3 py-2 text-sm cursor-pointer;
|
||||
@apply text-gray-300;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
i {
|
||||
@apply mr-1 text-lg text-gray-400;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
@apply ml-auto text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
@apply ml-auto px-2 py-0.5 text-xs rounded;
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
@apply ml-auto flex items-center gap-2;
|
||||
color: #fff;
|
||||
|
||||
.zoom-btn {
|
||||
@apply px-2 py-0.5 text-sm rounded cursor-pointer;
|
||||
background: #3a3a3a;
|
||||
|
||||
&:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
}
|
||||
|
||||
span:not(.zoom-btn) {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,14 +15,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useDialog } from 'naive-ui';
|
||||
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
|
||||
const dialog = useDialog();
|
||||
const windowData = window as any;
|
||||
|
||||
const minimize = () => {
|
||||
if (!isElectron.value) {
|
||||
return;
|
||||
}
|
||||
windowData.electronAPI.minimize();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!isElectron.value) {
|
||||
return;
|
||||
}
|
||||
dialog.warning({
|
||||
title: '提示',
|
||||
content: '确定要退出吗?',
|
||||
@@ -38,6 +46,9 @@ const close = () => {
|
||||
};
|
||||
|
||||
const drag = (event: MouseEvent) => {
|
||||
if (!isElectron.value) {
|
||||
return;
|
||||
}
|
||||
windowData.electronAPI.dragStart(event);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -10,8 +10,13 @@ import router from '@/router';
|
||||
import store from '@/store';
|
||||
|
||||
import App from './App.vue';
|
||||
import directives from './directive';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
Object.keys(directives).forEach((key: string) => {
|
||||
app.directive(key, directives[key as keyof typeof directives]);
|
||||
});
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -6,6 +6,7 @@ const layoutRouter = [
|
||||
title: '首页',
|
||||
icon: 'icon-Home',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
},
|
||||
@@ -17,6 +18,7 @@ const layoutRouter = [
|
||||
noScroll: true,
|
||||
icon: 'icon-Search',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/search/index.vue'),
|
||||
},
|
||||
@@ -27,6 +29,7 @@ const layoutRouter = [
|
||||
title: '歌单',
|
||||
icon: 'icon-Paper',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/list/index.vue'),
|
||||
},
|
||||
@@ -37,18 +40,29 @@ const layoutRouter = [
|
||||
title: 'MV',
|
||||
icon: 'icon-recordfill',
|
||||
keepAlive: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/mv/index.vue'),
|
||||
},
|
||||
// {
|
||||
// path: '/history',
|
||||
// name: 'history',
|
||||
// meta: {
|
||||
// title: '历史',
|
||||
// icon: 'icon-a-TicketStar',
|
||||
// keepAlive: true,
|
||||
// },
|
||||
// component: () => import('@/views/history/index.vue'),
|
||||
// },
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import('@/views/historyAndFavorite/index.vue'),
|
||||
meta: {
|
||||
title: '历史',
|
||||
title: '我的收藏和历史',
|
||||
icon: 'icon-a-TicketStar',
|
||||
keepAlive: true,
|
||||
},
|
||||
component: () => import('@/views/history/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
@@ -58,8 +72,20 @@ const layoutRouter = [
|
||||
icon: 'icon-Profile',
|
||||
keepAlive: true,
|
||||
noScroll: true,
|
||||
isMobile: true,
|
||||
},
|
||||
component: () => import('@/views/user/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/set',
|
||||
name: 'set',
|
||||
meta: {
|
||||
title: '设置',
|
||||
icon: 'ri-settings-3-fill',
|
||||
keepAlive: true,
|
||||
noScroll: true,
|
||||
},
|
||||
component: () => import('@/views/set/index.vue'),
|
||||
},
|
||||
];
|
||||
export default layoutRouter;
|
||||
|
||||
55
src/services/audioService.ts
Normal file
55
src/services/audioService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Howl } from 'howler';
|
||||
|
||||
class AudioService {
|
||||
private currentSound: Howl | null = null;
|
||||
|
||||
play(url: string) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.unload();
|
||||
}
|
||||
this.currentSound = null;
|
||||
this.currentSound = new Howl({
|
||||
src: [url],
|
||||
html5: true,
|
||||
autoplay: true,
|
||||
volume: localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1,
|
||||
});
|
||||
|
||||
return this.currentSound;
|
||||
}
|
||||
|
||||
getCurrentSound() {
|
||||
return this.currentSound;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.stop();
|
||||
this.currentSound.unload();
|
||||
this.currentSound = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听播放
|
||||
onPlay(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('play', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听暂停
|
||||
onPause(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('pause', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听结束
|
||||
onEnd(callback: () => void) {
|
||||
if (this.currentSound) {
|
||||
this.currentSound.on('end', callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const audioService = new AudioService();
|
||||
@@ -1,10 +1,22 @@
|
||||
import { createStore } from 'vuex';
|
||||
|
||||
import { getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { useMusicListHook } from '@/hooks/MusicListHook';
|
||||
import homeRouter from '@/router/home';
|
||||
import { SongResult } from '@/type/music';
|
||||
import { getMusicProxyUrl } from '@/utils';
|
||||
import type { SongResult } from '@/type/music';
|
||||
|
||||
// 默认设置
|
||||
const defaultSettings = {
|
||||
isProxy: false,
|
||||
noAnimate: false,
|
||||
animationSpeed: 1,
|
||||
author: 'Alger',
|
||||
authorUrl: 'https://github.com/algerkong',
|
||||
};
|
||||
|
||||
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
}
|
||||
|
||||
interface State {
|
||||
menus: any[];
|
||||
@@ -18,6 +30,10 @@ interface State {
|
||||
setData: any;
|
||||
lyric: any;
|
||||
isMobile: boolean;
|
||||
searchValue: string;
|
||||
searchType: number;
|
||||
favoriteList: number[];
|
||||
playMode: number;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
@@ -26,28 +42,26 @@ const state: State = {
|
||||
isPlay: false,
|
||||
playMusic: {} as SongResult,
|
||||
playMusicUrl: '',
|
||||
user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null,
|
||||
user: getLocalStorageItem('user', null),
|
||||
playList: [],
|
||||
playListIndex: 0,
|
||||
setData: null,
|
||||
setData: defaultSettings,
|
||||
lyric: {},
|
||||
isMobile: false,
|
||||
searchValue: '',
|
||||
searchType: 1,
|
||||
favoriteList: getLocalStorageItem('favoriteList', []),
|
||||
playMode: getLocalStorageItem('playMode', 0),
|
||||
};
|
||||
|
||||
const windowData = window as any;
|
||||
|
||||
const musicHistory = useMusicHistory();
|
||||
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
|
||||
|
||||
const mutations = {
|
||||
setMenus(state: State, menus: any[]) {
|
||||
state.menus = menus;
|
||||
},
|
||||
async setPlay(state: State, playMusic: SongResult) {
|
||||
state.playMusic = { ...playMusic, playLoading: true };
|
||||
state.playMusicUrl = await getSongUrl(playMusic.id);
|
||||
state.play = true;
|
||||
musicHistory.addMusic(playMusic);
|
||||
state.playMusic.playLoading = false;
|
||||
await handlePlayMusic(state, playMusic);
|
||||
},
|
||||
setIsPlay(state: State, isPlay: boolean) {
|
||||
state.isPlay = isPlay;
|
||||
@@ -60,52 +74,61 @@ const mutations = {
|
||||
state.playList = playList;
|
||||
},
|
||||
async nextPlay(state: State) {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true;
|
||||
return;
|
||||
}
|
||||
state.playListIndex = (state.playListIndex + 1) % state.playList.length;
|
||||
await updatePlayMusic(state);
|
||||
await nextPlay(state);
|
||||
},
|
||||
async prevPlay(state: State) {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true;
|
||||
return;
|
||||
}
|
||||
state.playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
|
||||
await updatePlayMusic(state);
|
||||
await prevPlay(state);
|
||||
},
|
||||
async setSetData(state: State, setData: any) {
|
||||
setSetData(state: State, setData: any) {
|
||||
state.setData = setData;
|
||||
windowData.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
||||
const isElectron = (window as any).electronAPI !== undefined;
|
||||
if (isElectron) {
|
||||
(window as any).electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
||||
} else {
|
||||
localStorage.setItem('appSettings', JSON.stringify(setData));
|
||||
}
|
||||
},
|
||||
addToFavorite(state: State, songId: number) {
|
||||
if (!state.favoriteList.includes(songId)) {
|
||||
state.favoriteList = [songId, ...state.favoriteList];
|
||||
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
|
||||
}
|
||||
},
|
||||
removeFromFavorite(state: State, songId: number) {
|
||||
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
|
||||
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
|
||||
},
|
||||
togglePlayMode(state: State) {
|
||||
state.playMode = state.playMode === 0 ? 1 : 0;
|
||||
localStorage.setItem('playMode', JSON.stringify(state.playMode));
|
||||
},
|
||||
};
|
||||
|
||||
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 actions = {
|
||||
initializeSettings({ commit }: { commit: any }) {
|
||||
const isElectron = (window as any).electronAPI !== undefined;
|
||||
|
||||
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);
|
||||
if (isElectron) {
|
||||
const setData = (window as any).electron.ipcRenderer.getStoreValue('set');
|
||||
commit('setSetData', setData || defaultSettings);
|
||||
} else {
|
||||
const savedSettings = localStorage.getItem('appSettings');
|
||||
if (savedSettings) {
|
||||
commit('setSetData', {
|
||||
...defaultSettings,
|
||||
...JSON.parse(savedSettings),
|
||||
});
|
||||
} else {
|
||||
commit('setSetData', defaultSettings);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const store = createStore({
|
||||
state,
|
||||
mutations,
|
||||
actions,
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -3,6 +3,14 @@ export interface IRecommendMusic {
|
||||
category: number;
|
||||
result: SongResult[];
|
||||
}
|
||||
export interface ILyricText {
|
||||
text: string;
|
||||
trText: string;
|
||||
}
|
||||
export interface ILyric {
|
||||
lrcTimeArray: number[];
|
||||
lrcArray: ILyricText[];
|
||||
}
|
||||
|
||||
export interface SongResult {
|
||||
id: number;
|
||||
@@ -16,9 +24,15 @@ export interface SongResult {
|
||||
alg: string;
|
||||
count?: number;
|
||||
playLoading?: boolean;
|
||||
ar?: Artist[];
|
||||
al?: Album;
|
||||
backgroundColor?: string;
|
||||
primaryColor?: string;
|
||||
playMusicUrl?: string;
|
||||
lyric?: ILyric;
|
||||
}
|
||||
|
||||
interface Song {
|
||||
export interface Song {
|
||||
name: string;
|
||||
id: number;
|
||||
position: number;
|
||||
@@ -64,6 +78,10 @@ interface Song {
|
||||
lMusic: BMusic;
|
||||
exclusive: boolean;
|
||||
privilege: Privilege;
|
||||
count?: number;
|
||||
playLoading?: boolean;
|
||||
picUrl?: string;
|
||||
ar: Artist[];
|
||||
}
|
||||
|
||||
interface Privilege {
|
||||
|
||||
@@ -15,12 +15,12 @@ interface SortCategories {
|
||||
|
||||
interface SortAll {
|
||||
name: string;
|
||||
resourceCount: number;
|
||||
imgId: number;
|
||||
resourceCount?: number;
|
||||
imgId?: number;
|
||||
imgUrl?: any;
|
||||
type: number;
|
||||
category: number;
|
||||
resourceType: number;
|
||||
hot: boolean;
|
||||
activity: boolean;
|
||||
type?: number;
|
||||
category?: number;
|
||||
resourceType?: number;
|
||||
hot?: boolean;
|
||||
activity?: boolean;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,23 @@ export const setBackgroundImg = (url: String) => {
|
||||
};
|
||||
// 设置动画类型
|
||||
export const setAnimationClass = (type: String) => {
|
||||
return `animate__animated ${type}`;
|
||||
if (store.state.setData && store.state.setData.noAnimate) {
|
||||
return '';
|
||||
}
|
||||
const speed = store.state.setData?.animationSpeed || 1;
|
||||
|
||||
let speedClass = '';
|
||||
if (speed <= 0.3) speedClass = 'animate__slower';
|
||||
else if (speed <= 0.8) speedClass = 'animate__slow';
|
||||
else if (speed >= 2.5) speedClass = 'animate__faster';
|
||||
else if (speed >= 1.5) speedClass = 'animate__fast';
|
||||
|
||||
return `animate__animated ${type}${speedClass ? ` ${speedClass}` : ''}`;
|
||||
};
|
||||
// 设置动画延时
|
||||
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
|
||||
return `animation-delay:${index * time}ms`;
|
||||
const speed = store.state.setData?.animationSpeed || 1;
|
||||
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
||||
};
|
||||
|
||||
// 将秒转换为分钟和秒
|
||||
@@ -28,16 +40,21 @@ export const secondToMinute = (s: number) => {
|
||||
};
|
||||
|
||||
// 格式化数字 千,万, 百万, 千万,亿
|
||||
const units = [
|
||||
{ value: 1e8, symbol: '亿' },
|
||||
{ value: 1e4, symbol: '万' },
|
||||
];
|
||||
|
||||
export const formatNumber = (num: string | number) => {
|
||||
num = Number(num);
|
||||
if (num < 10000) {
|
||||
return num;
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (num >= units[i].value) {
|
||||
return `${(num / units[i].value).toFixed(1)}${units[i].symbol}`;
|
||||
}
|
||||
}
|
||||
if (num < 100000000) {
|
||||
return `${(num / 10000).toFixed(1)}万`;
|
||||
}
|
||||
return `${(num / 100000000).toFixed(1)}亿`;
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const windowData = window as any;
|
||||
export const getIsMc = () => {
|
||||
if (!windowData.electron) {
|
||||
@@ -48,7 +65,7 @@ export const getIsMc = () => {
|
||||
}
|
||||
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) => {
|
||||
if (!getIsMc()) {
|
||||
@@ -58,14 +75,14 @@ export const getMusicProxyUrl = (url: string) => {
|
||||
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 imgUrl = `${url}?param=${size}`;
|
||||
if (!getIsMc()) {
|
||||
return imgUrl;
|
||||
}
|
||||
return `${bdUrl}${encodeURIComponent(imgUrl)}`;
|
||||
});
|
||||
};
|
||||
|
||||
export const isMobile = computed(() => {
|
||||
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);
|
||||
};
|
||||
205
src/views/favorite/index.vue
Normal file
205
src/views/favorite/index.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
|
||||
<div class="favorite-header" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
<h2>我的收藏</h2>
|
||||
<div class="favorite-count">共 {{ favoriteList.length }} 首</div>
|
||||
</div>
|
||||
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')">
|
||||
<n-scrollbar ref="scrollbarRef" class="favorite-content" @scroll="handleScroll">
|
||||
<div v-if="favoriteList.length === 0" class="empty-tip">
|
||||
<n-empty description="还没有收藏歌曲" />
|
||||
</div>
|
||||
<div v-else class="favorite-list">
|
||||
<song-item
|
||||
v-for="(song, index) in favoriteSongs"
|
||||
:key="song.id"
|
||||
:item="song"
|
||||
:favorite="!isComponent"
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
@play="handlePlay"
|
||||
/>
|
||||
<div v-if="isComponent" class="favorite-list-more text-center">
|
||||
<n-button text type="primary" @click="handleMore">查看更多</n-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-if="noMore" class="no-more-tip">没有更多了</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
const store = useStore();
|
||||
const favoriteList = computed(() => store.state.favoriteList);
|
||||
const favoriteSongs = ref<SongResult[]>([]);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const scrollbarRef = ref();
|
||||
|
||||
// 无限滚动相关
|
||||
const pageSize = 16;
|
||||
const currentPage = ref(1);
|
||||
|
||||
const props = defineProps({
|
||||
isComponent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 获取当前页的收藏歌曲ID
|
||||
const getCurrentPageIds = () => {
|
||||
const reversedList = [...favoriteList.value];
|
||||
const startIndex = (currentPage.value - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return reversedList.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
// 获取收藏歌曲详情
|
||||
const getFavoriteSongs = async () => {
|
||||
if (favoriteList.value.length === 0) {
|
||||
favoriteSongs.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.isComponent && favoriteSongs.value.length >= 16) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const currentIds = getCurrentPageIds();
|
||||
const res = await getMusicDetail(currentIds);
|
||||
if (res.data.songs) {
|
||||
const newSongs = res.data.songs.map((song: SongResult) => ({
|
||||
...song,
|
||||
picUrl: song.al?.picUrl || '',
|
||||
}));
|
||||
|
||||
// 追加新数据而不是替换
|
||||
if (currentPage.value === 1) {
|
||||
favoriteSongs.value = newSongs;
|
||||
} else {
|
||||
favoriteSongs.value = [...favoriteSongs.value, ...newSongs];
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
noMore.value = favoriteSongs.value.length >= favoriteList.value.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取收藏歌曲失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, offsetHeight } = e.target;
|
||||
const threshold = 100; // 距离底部多少像素时加载更多
|
||||
|
||||
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
|
||||
currentPage.value++;
|
||||
getFavoriteSongs();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getFavoriteSongs();
|
||||
});
|
||||
|
||||
// 监听收藏列表变化
|
||||
watch(
|
||||
favoriteList,
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
noMore.value = false;
|
||||
getFavoriteSongs();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', favoriteSongs.value);
|
||||
};
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
return setAnimationDelay(index, 30);
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const handleMore = () => {
|
||||
router.push('/favorite');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.favorite-page {
|
||||
@apply h-full flex flex-col pt-2;
|
||||
|
||||
.favorite-header {
|
||||
@apply flex items-center justify-between flex-shrink-0 px-4;
|
||||
|
||||
h2 {
|
||||
@apply text-xl font-bold pb-2;
|
||||
}
|
||||
|
||||
.favorite-count {
|
||||
@apply text-gray-400 text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-main {
|
||||
@apply flex flex-col flex-grow min-h-0;
|
||||
|
||||
.favorite-content {
|
||||
@apply flex-grow min-h-0;
|
||||
|
||||
.empty-tip {
|
||||
@apply h-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.favorite-list {
|
||||
@apply space-y-2 pb-4 px-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
@apply flex justify-center items-center py-20;
|
||||
}
|
||||
|
||||
.no-more-tip {
|
||||
@apply text-center text-gray-400 py-4 text-sm;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.favorite-page {
|
||||
@apply p-4;
|
||||
|
||||
.favorite-header {
|
||||
@apply mb-4;
|
||||
|
||||
h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +1,143 @@
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<div class="title">播放历史</div>
|
||||
<n-scrollbar :size="100">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">播放历史</div>
|
||||
<n-scrollbar ref="scrollbarRef" :size="100" @scroll="handleScroll">
|
||||
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
|
||||
<div
|
||||
v-for="(item, index) in musicList"
|
||||
v-for="(item, index) in displayList"
|
||||
:key="item.id"
|
||||
class="history-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
>
|
||||
<song-item class="history-item-content" :item="item" />
|
||||
<div class="history-item-count">
|
||||
<song-item class="history-item-content" :item="item" @play="handlePlay" />
|
||||
<div class="history-item-count min-w-[60px]">
|
||||
{{ item.count }}
|
||||
</div>
|
||||
<div class="history-item-delete">
|
||||
<i class="iconfont icon-close" @click="delMusic(item)"></i>
|
||||
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-if="noMore" class="no-more-tip">没有更多了</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'History',
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { delMusic, musicList } = useMusicHistory();
|
||||
const scrollbarRef = ref();
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
const displayList = ref<SongResult[]>([]);
|
||||
|
||||
// 无限滚动相关配置
|
||||
const pageSize = 20;
|
||||
const currentPage = ref(1);
|
||||
|
||||
// 获取当前页的音乐详情
|
||||
const getHistorySongs = async () => {
|
||||
if (musicList.value.length === 0) {
|
||||
displayList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const startIndex = (currentPage.value - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const currentPageItems = musicList.value.slice(startIndex, endIndex);
|
||||
|
||||
const currentIds = currentPageItems.map((item) => item.id);
|
||||
const res = await getMusicDetail(currentIds);
|
||||
|
||||
if (res.data.songs) {
|
||||
const newSongs = res.data.songs.map((song: SongResult) => {
|
||||
const historyItem = currentPageItems.find((item) => item.id === song.id);
|
||||
return {
|
||||
...song,
|
||||
picUrl: song.al?.picUrl || '',
|
||||
count: historyItem?.count || 0,
|
||||
};
|
||||
});
|
||||
|
||||
if (currentPage.value === 1) {
|
||||
displayList.value = newSongs;
|
||||
} else {
|
||||
displayList.value = [...displayList.value, ...newSongs];
|
||||
}
|
||||
|
||||
noMore.value = displayList.value.length >= musicList.value.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史记录失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, offsetHeight } = e.target;
|
||||
const threshold = 100; // 距离底部多少像素时加载更多
|
||||
|
||||
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
|
||||
currentPage.value++;
|
||||
getHistorySongs();
|
||||
}
|
||||
};
|
||||
|
||||
// 播放全部
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', displayList.value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getHistorySongs();
|
||||
});
|
||||
|
||||
// 重写删除方法,需要同时更新 displayList
|
||||
const handleDelMusic = async (item: SongResult) => {
|
||||
delMusic(item);
|
||||
musicList.value = musicList.value.filter((music) => music.id !== item.id);
|
||||
displayList.value = displayList.value.filter((music) => music.id !== item.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.history-page {
|
||||
@apply h-full w-full pt-2;
|
||||
.title {
|
||||
@apply pl-4 text-xl font-bold;
|
||||
@apply pl-4 text-xl font-bold pb-2 px-4;
|
||||
}
|
||||
|
||||
.history-list-content {
|
||||
@apply px-4 mt-2 pb-28;
|
||||
@apply mt-2 pb-28 px-4;
|
||||
.history-item {
|
||||
@apply flex items-center justify-between;
|
||||
&-content {
|
||||
@apply flex-1;
|
||||
}
|
||||
&-count {
|
||||
@apply px-4 text-lg;
|
||||
@apply px-4 text-lg text-center;
|
||||
}
|
||||
&-delete {
|
||||
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
|
||||
@@ -57,4 +145,12 @@ const { delMusic, musicList } = useMusicHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
@apply flex justify-center items-center py-8;
|
||||
}
|
||||
|
||||
.no-more-tip {
|
||||
@apply text-center text-gray-400 py-4 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
13
src/views/historyAndFavorite/index.vue
Normal file
13
src/views/historyAndFavorite/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex gap-4 h-full p-4">
|
||||
<favorite class="flex-1 bg-[#0d0d0d] border border-[#374151] rounded-2xl overflow-hidden" />
|
||||
<history class="flex-1 bg-[#0d0d0d] border border-[#374151] rounded-2xl overflow-hidden" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Favorite from '@/views/favorite/index.vue';
|
||||
import History from '@/views/history/index.vue';
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -9,7 +9,10 @@
|
||||
<!-- 本周最热音乐 -->
|
||||
<recommend-songlist />
|
||||
<!-- 推荐最新专辑 -->
|
||||
<recommend-album />
|
||||
<div>
|
||||
<favorite-list is-component />
|
||||
<recommend-album />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
@@ -17,11 +20,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { isMobile } from '@/utils';
|
||||
import FavoriteList from '@/views/favorite/index.vue';
|
||||
|
||||
const RecommendSinger = defineAsyncComponent(() => import('@/components/RecommendSinger.vue'));
|
||||
const PlaylistType = defineAsyncComponent(() => import('@/components/PlaylistType.vue'));
|
||||
const RecommendSonglist = defineAsyncComponent(() => import('@/components/RecommendSonglist.vue'));
|
||||
const RecommendAlbum = defineAsyncComponent(() => import('@/components/RecommendAlbum.vue'));
|
||||
defineOptions({
|
||||
name: 'Home',
|
||||
});
|
||||
@@ -35,7 +35,25 @@ defineOptions({
|
||||
@apply mt-6 flex mb-28;
|
||||
}
|
||||
|
||||
.mobile .main-content {
|
||||
@apply flex-col mx-4;
|
||||
.mobile {
|
||||
.main-content {
|
||||
@apply flex-col mx-4;
|
||||
}
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-full;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.favorite-page) {
|
||||
@apply p-0 mx-4 h-[300px];
|
||||
.favorite-header {
|
||||
@apply mb-0 px-0 !important;
|
||||
h2 {
|
||||
@apply text-lg font-bold;
|
||||
}
|
||||
}
|
||||
.favorite-list {
|
||||
@apply px-0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,136 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { getListByCat, getListDetail, getRecommendList } from '@/api/list';
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
import { getListByCat, getListDetail } from '@/api/list';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
import type { IRecommendItem } from '@/type/list';
|
||||
import type { IListDetail } from '@/type/listDetail';
|
||||
import type { IPlayListSort } from '@/type/playlist';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'List',
|
||||
});
|
||||
|
||||
const recommendList = ref();
|
||||
const TOTAL_ITEMS = 42; // 每页数量
|
||||
|
||||
const recommendList = ref<any[]>([]);
|
||||
const showMusic = ref(false);
|
||||
const page = ref(0);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
|
||||
// 计算每个项目的动画延迟
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % TOTAL_ITEMS;
|
||||
return setAnimationDelay(currentPageIndex, 30);
|
||||
};
|
||||
|
||||
const recommendItem = ref<IRecommendItem | null>();
|
||||
const listDetail = ref<IListDetail | null>();
|
||||
const listLoading = ref(true);
|
||||
|
||||
const selectRecommendItem = async (item: IRecommendItem) => {
|
||||
listLoading.value = true;
|
||||
recommendItem.value = null;
|
||||
listDetail.value = null;
|
||||
showMusic.value = true;
|
||||
recommendItem.value = item;
|
||||
const { data } = await getListDetail(item.id);
|
||||
listDetail.value = data;
|
||||
listLoading.value = false;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const listTitle = ref(route.query.type || '歌单列表');
|
||||
|
||||
const loadList = async (type: string) => {
|
||||
const params = {
|
||||
cat: type || '',
|
||||
limit: 30,
|
||||
offset: 0,
|
||||
};
|
||||
const { data } = await getListByCat(params);
|
||||
recommendList.value = data.playlists;
|
||||
const loading = ref(false);
|
||||
const loadList = async (type: string, isLoadMore = false) => {
|
||||
if (!hasMore.value && isLoadMore) return;
|
||||
if (isLoadMore) {
|
||||
isLoadingMore.value = true;
|
||||
} else {
|
||||
loading.value = true;
|
||||
page.value = 0;
|
||||
recommendList.value = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
cat: type || '',
|
||||
limit: TOTAL_ITEMS,
|
||||
offset: page.value * TOTAL_ITEMS,
|
||||
};
|
||||
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);
|
||||
} else {
|
||||
getRecommendList().then((res: { data: { result: any } }) => {
|
||||
recommendList.value = res.data.result;
|
||||
});
|
||||
}
|
||||
// 监听滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
// 距离底部100px时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||
loadList(route.query.type as string, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加歌单分类相关的代码
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
const currentType = ref((route.query.type as string) || '每日推荐');
|
||||
|
||||
const getAnimationDelay = computed(() => {
|
||||
return (index: number) => setAnimationDelay(index, 30);
|
||||
});
|
||||
|
||||
// 加载歌单分类
|
||||
const loadPlaylistCategory = async () => {
|
||||
const { data } = await getPlaylistCategory();
|
||||
playlistCategory.value = {
|
||||
...data,
|
||||
sub: [
|
||||
{
|
||||
name: '每日推荐',
|
||||
category: 0,
|
||||
},
|
||||
...data.sub,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const handleClickPlaylistType = (type: string) => {
|
||||
currentType.value = type;
|
||||
listTitle.value = type;
|
||||
loading.value = true;
|
||||
loadList(type);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadPlaylistCategory(); // 添加加载歌单分类
|
||||
currentType.value = (route.query.type as string) || currentType.value;
|
||||
loadList(currentType.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
async (newParams) => {
|
||||
if (newParams.type) {
|
||||
recommendList.value = null;
|
||||
recommendList.value = [];
|
||||
listTitle.value = newParams.type || '歌单列表';
|
||||
currentType.value = newParams.type as string;
|
||||
loading.value = true;
|
||||
loadList(newParams.type as string);
|
||||
}
|
||||
},
|
||||
@@ -59,16 +139,32 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
|
||||
<!-- 修改歌单分类部分 -->
|
||||
<div class="play-list-type">
|
||||
<n-scrollbar x-scrollable>
|
||||
<div class="categories-wrapper">
|
||||
<span
|
||||
v-for="(item, index) in playlistCategory?.sub"
|
||||
:key="item.name"
|
||||
class="play-list-type-item"
|
||||
:class="[setAnimationClass('animate__bounceIn'), { active: currentType === item.name }]"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="handleClickPlaylistType(item.name)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<!-- 歌单列表 -->
|
||||
<n-scrollbar class="recommend" :size="100" @click="showMusic = false">
|
||||
<div v-if="recommendList" class="recommend-list">
|
||||
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
|
||||
<div v-loading="loading" class="recommend-list">
|
||||
<div
|
||||
v-for="(item, index) in recommendList"
|
||||
:key="item.id"
|
||||
class="recommend-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
@click.stop="selectRecommendItem(item)"
|
||||
>
|
||||
<div class="recommend-item-img">
|
||||
@@ -88,39 +184,51 @@ watch(
|
||||
<div class="recommend-item-title">{{ item.name }}</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>
|
||||
<music-list
|
||||
v-model:show="showMusic"
|
||||
v-model:loading="listLoading"
|
||||
:name="recommendItem?.name || ''"
|
||||
:song-list="listDetail?.playlist.tracks || []"
|
||||
:list-info="listDetail?.playlist"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-page {
|
||||
@apply relative h-full w-full px-4;
|
||||
@apply relative h-full w-full;
|
||||
}
|
||||
|
||||
.recommend {
|
||||
@apply w-full h-full bg-none;
|
||||
&-title {
|
||||
@apply text-lg font-bold text-white pb-4;
|
||||
@apply text-lg font-bold text-white pb-2;
|
||||
}
|
||||
|
||||
&-list {
|
||||
@apply grid gap-6 pb-28;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13%, 1fr));
|
||||
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
&-item {
|
||||
@apply flex flex-col;
|
||||
&-img {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
@apply rounded-xl overflow-hidden relative w-full aspect-square;
|
||||
&-img {
|
||||
@apply block w-full h-full;
|
||||
}
|
||||
img {
|
||||
@apply absolute top-0 left-0 w-full h-full object-cover rounded-xl;
|
||||
}
|
||||
&:hover img {
|
||||
@apply hover:scale-110 transition-all duration-300 ease-in-out;
|
||||
}
|
||||
&-img {
|
||||
@apply h-full w-full rounded-xl overflow-hidden;
|
||||
}
|
||||
.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;
|
||||
background-color: #00000088;
|
||||
@@ -138,10 +246,7 @@ watch(
|
||||
}
|
||||
|
||||
.play-count {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
font-size: 14px;
|
||||
@apply absolute top-2 left-2 text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,9 +256,53 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply flex items-center justify-center py-4 text-sm text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4 text-sm text-gray-500;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.recommend-title {
|
||||
@apply text-xl font-bold px-4;
|
||||
}
|
||||
|
||||
.recommend-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(25%, 1fr));
|
||||
@apply px-4 gap-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// 添加歌单分类样式
|
||||
.play-list-type {
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4;
|
||||
}
|
||||
|
||||
.categories-wrapper {
|
||||
@apply flex items-center py-2 pb-4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply py-2 px-3 mr-3 inline-block border border-gray-700 rounded-xl cursor-pointer transition-all duration-300;
|
||||
background-color: #1a1a1a;
|
||||
|
||||
&:hover {
|
||||
@apply bg-green-600/50;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-green-600 border-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.play-list-type {
|
||||
@apply mx-0 w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { checkQr, createQr, getQrKey, getUserDetail, loginByCellphone } from '@/api/login';
|
||||
import { isMobile, setAnimationClass } from '@/utils';
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Login',
|
||||
@@ -14,6 +14,7 @@ defineOptions({
|
||||
const message = useMessage();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const isQr = ref(false);
|
||||
|
||||
const qrUrl = ref<string>();
|
||||
onMounted(() => {
|
||||
@@ -24,6 +25,11 @@ const timerRef = ref(null);
|
||||
|
||||
const loadLogin = async () => {
|
||||
try {
|
||||
if (timerRef.value) {
|
||||
clearInterval(timerRef.value);
|
||||
timerRef.value = null;
|
||||
}
|
||||
if (!isQr.value) return;
|
||||
const qrKey = await getQrKey();
|
||||
const key = qrKey.data.data.unikey;
|
||||
const { data } = await createQr(key);
|
||||
@@ -65,15 +71,23 @@ const timerIsQr = (key: string) => {
|
||||
clearInterval(timer);
|
||||
timerRef.value = null;
|
||||
}
|
||||
}, 2000);
|
||||
}, 3000);
|
||||
|
||||
return timer;
|
||||
};
|
||||
|
||||
// 离开页面时
|
||||
onBeforeUnmount(() => {
|
||||
if (timerRef.value) {
|
||||
clearInterval(timerRef.value);
|
||||
timerRef.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 是否扫码登陆
|
||||
const isQr = ref(!isMobile.value);
|
||||
const chooseQr = () => {
|
||||
isQr.value = !isQr.value;
|
||||
loadLogin();
|
||||
};
|
||||
|
||||
// 手机号登录
|
||||
@@ -108,6 +122,7 @@ const loginPhone = async () => {
|
||||
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
|
||||
<input v-model="password" class="phone-input" type="password" placeholder="密码" />
|
||||
</div>
|
||||
<div class="text">使用网易云账号登录</div>
|
||||
<n-button class="btn-login" @click="loginPhone()">登录</n-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,14 +143,14 @@ const loginPhone = async () => {
|
||||
}
|
||||
|
||||
.text {
|
||||
@apply mt-4 text-green-500 text-xs;
|
||||
@apply mt-4 text-white text-xs;
|
||||
}
|
||||
|
||||
.phone-login {
|
||||
width: 350px;
|
||||
height: 550px;
|
||||
@apply rounded-2xl rounded-b-none bg-cover bg-no-repeat relative overflow-hidden;
|
||||
background-image: url(http://tva4.sinaimg.cn/large/006opRgRgy1gw8nf6no7uj30rs15n0x7.jpg);
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='400' height='560' preserveAspectRatio='none' viewBox='0 0 400 560'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1066%26quot%3b)' fill='none'%3e%3crect width='400' height='560' x='0' y='0' fill='rgba(24%2c 106%2c 59%2c 1)'%3e%3c/rect%3e%3cpath d='M0%2c234.738C43.535%2c236.921%2c80.103%2c205.252%2c116.272%2c180.923C151.738%2c157.067%2c188.295%2c132.929%2c207.855%2c94.924C227.898%2c55.979%2c233.386%2c10.682%2c226.119%2c-32.511C218.952%2c-75.107%2c199.189%2c-115.793%2c167.469%2c-145.113C137.399%2c-172.909%2c92.499%2c-171.842%2c55.779%2c-189.967C8.719%2c-213.196%2c-28.344%2c-282.721%2c-78.217%2c-266.382C-128.725%2c-249.834%2c-111.35%2c-166.696%2c-143.781%2c-124.587C-173.232%2c-86.348%2c-244.72%2c-83.812%2c-255.129%2c-36.682C-265.368%2c9.678%2c-217.952%2c48.26%2c-190.512%2c87.004C-167.691%2c119.226%2c-140.216%2c145.431%2c-109.013%2c169.627C-74.874%2c196.1%2c-43.147%2c232.575%2c0%2c234.738' fill='%23114b2a'%3e%3c/path%3e%3cpath d='M400 800.9010000000001C443.973 795.023 480.102 765.6 513.011 735.848 541.923 709.71 561.585 676.6320000000001 577.037 640.85 592.211 605.712 606.958 568.912 601.458 531.035 595.962 493.182 568.394 464.36400000000003 546.825 432.775 522.317 396.88300000000004 507.656 347.475 466.528 333.426 425.366 319.366 384.338 352.414 342.111 362.847 297.497 373.869 242.385 362.645 211.294 396.486 180.212 430.318 192.333 483.83299999999997 188.872 529.644 185.656 572.218 178.696 614.453 191.757 655.101 205.885 699.068 227.92 742.4110000000001 265.75 768.898 304.214 795.829 353.459 807.1220000000001 400 800.9010000000001' fill='%231f894c'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1066'%3e%3crect width='400' height='560' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
|
||||
background-color: #383838;
|
||||
box-shadow: inset 0px 0px 20px 5px #0000005e;
|
||||
|
||||
|
||||
@@ -1,76 +1,126 @@
|
||||
<template>
|
||||
<div class="lyric-window" :class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]">
|
||||
<div class="drag-bar"></div>
|
||||
<div class="lyric-bar" :class="{ 'lyric-bar-hover': isDrag }">
|
||||
<div class="buttons">
|
||||
<!-- <div class="music-buttons">
|
||||
<div @click="handlePrev">
|
||||
<i class="iconfont icon-prev"></i>
|
||||
</div>
|
||||
<div class="music-buttons-play" @click="playMusicEvent">
|
||||
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
|
||||
</div>
|
||||
<div @click="handleEnded">
|
||||
<i class="iconfont icon-next"></i>
|
||||
</div>
|
||||
<div
|
||||
class="lyric-window"
|
||||
:class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div class="drag-overlay"></div>
|
||||
<!-- 顶部控制栏 -->
|
||||
<div class="control-bar" :class="{ 'control-bar-show': showControls }">
|
||||
<div class="font-size-controls">
|
||||
<n-button-group>
|
||||
<n-button quaternary size="small" :disabled="fontSize <= 12" @click="decreaseFontSize">
|
||||
<i class="ri-subtract-line"></i>
|
||||
</n-button>
|
||||
<n-button quaternary size="small" :disabled="fontSize >= 48" @click="increaseFontSize">
|
||||
<i class="ri-add-line"></i>
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
<div>{{ staticData.playMusic.name }}</div>
|
||||
</div>
|
||||
<!-- 添加播放控制按钮 -->
|
||||
<div class="play-controls">
|
||||
<div class="control-button" @click="handlePrev">
|
||||
<i class="ri-skip-back-fill"></i>
|
||||
</div>
|
||||
<div class="control-button play-button" @click="handlePlayPause">
|
||||
<i :class="dynamicData.isPlay ? 'ri-pause-fill' : 'ri-play-fill'"></i>
|
||||
</div>
|
||||
<div class="control-button" @click="handleNext">
|
||||
<i class="ri-skip-forward-fill"></i>
|
||||
</div>
|
||||
</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 class="control-button" @click="handleTop">
|
||||
<i class="ri-pushpin-line" :class="{ active: lyricSetting.isTop }"></i>
|
||||
</div> -->
|
||||
<div class="button check-theme" @click="checkTheme">
|
||||
<i v-if="lyricSetting.theme === 'light'" class="icon ri-sun-line"></i>
|
||||
<i v-else class="icon ri-moon-line"></i>
|
||||
<div id="lyric-lock" class="control-button" @click="handleLock">
|
||||
<i v-if="lyricSetting.isLock" class="ri-lock-line"></i>
|
||||
<i v-else class="ri-lock-unlock-line"></i>
|
||||
</div>
|
||||
<div class="button">
|
||||
<i class="icon ri-share-2-line" :class="{ checked: lyricSetting.isTop }" @click="handleTop"></i>
|
||||
</div>
|
||||
<div class="button button-lock" @click="handleLock">
|
||||
<i v-if="lyricSetting.isLock" class="icon ri-lock-line"></i>
|
||||
<i v-else class="icon ri-lock-unlock-line"></i>
|
||||
</div>
|
||||
<div class="button">
|
||||
<i class="icon ri-close-circle-line" @click="handleClose"></i>
|
||||
<div class="control-button" @click="handleClose">
|
||||
<i class="ri-close-line"></i>
|
||||
</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>
|
||||
<p class="lyric-current">{{ lyricData.currentLrc.trText }}</p>
|
||||
<template v-if="lyricData.lrcArray[lyricData.nowIndex + 1]">
|
||||
<h2 class="lyric lyric-next">
|
||||
{{ lyricData.lrcArray[lyricData.nowIndex + 1].text }}
|
||||
</h2>
|
||||
<p class="lyric-next">{{ lyricData.nextLrc.trText }}</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 歌词显示区域 -->
|
||||
<div ref="containerRef" class="lyric-container">
|
||||
<div class="lyric-scroll">
|
||||
<div class="lyric-wrapper" :style="wrapperStyle">
|
||||
<template v-if="staticData.lrcArray?.length > 0">
|
||||
<div
|
||||
v-for="(line, index) in staticData.lrcArray"
|
||||
:key="index"
|
||||
class="lyric-line"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIpcRenderer } from '@vueuse/electron';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { SongResult } from '@/type/music';
|
||||
|
||||
defineOptions({
|
||||
name: 'Lyric',
|
||||
});
|
||||
const windowData = window as any;
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const containerHeight = ref(0);
|
||||
const lineHeight = ref(60);
|
||||
const currentIndex = ref(0);
|
||||
// 字体大小控制
|
||||
const fontSize = ref(24); // 默认字体大小
|
||||
const fontSizeStep = 2; // 每次整的步长
|
||||
const animationFrameId = ref<number | null>(null);
|
||||
const lastUpdateTime = ref(performance.now());
|
||||
|
||||
const ipcRenderer = useIpcRenderer();
|
||||
|
||||
const lyricData = ref({
|
||||
currentLrc: {
|
||||
text: '',
|
||||
trText: '',
|
||||
},
|
||||
nextLrc: {
|
||||
text: '',
|
||||
trText: '',
|
||||
},
|
||||
currentTime: 0,
|
||||
nextTime: 0,
|
||||
nowTime: 0,
|
||||
// 静态数据
|
||||
const staticData = ref<{
|
||||
lrcArray: Array<{ text: string; trText: string }>;
|
||||
lrcTimeArray: number[];
|
||||
allTime: number;
|
||||
playMusic: SongResult;
|
||||
}>({
|
||||
lrcArray: [],
|
||||
lrcTimeArray: [],
|
||||
allTime: 0,
|
||||
playMusic: {} as SongResult,
|
||||
});
|
||||
|
||||
// 动态数据
|
||||
const dynamicData = ref({
|
||||
nowTime: 0,
|
||||
startCurrentTime: 0,
|
||||
lrcArray: [] as any,
|
||||
lrcTimeArray: [] as any,
|
||||
nowIndex: 0,
|
||||
nextTime: 0,
|
||||
isPlay: true,
|
||||
});
|
||||
|
||||
const lyricSetting = ref({
|
||||
@@ -83,16 +133,309 @@ 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) {
|
||||
isHovering.value = true;
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', true);
|
||||
} else {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标离开窗口
|
||||
const handleMouseLeave = () => {
|
||||
if (!lyricSetting.value.isLock) return;
|
||||
isHovering.value = false;
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
|
||||
};
|
||||
|
||||
// 监听锁定状态变化
|
||||
watch(
|
||||
() => lyricSetting.value.isLock,
|
||||
(newLock: boolean) => {
|
||||
if (newLock) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ipcRenderer.on('receive-lyric', (event, data) => {
|
||||
// 初始化时,如果是锁定状态,确保控制栏隐藏
|
||||
if (lyricSetting.value.isLock) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearHideTimer();
|
||||
});
|
||||
|
||||
// 计算歌词滚动位置
|
||||
const wrapperStyle = computed(() => {
|
||||
if (!containerHeight.value) {
|
||||
return {
|
||||
transform: 'translateY(0)',
|
||||
transition: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// 计算容器中心点
|
||||
const containerCenter = containerHeight.value / 2;
|
||||
|
||||
// 计算当前行到顶部的距离(包含padding)
|
||||
const currentLineTop = currentIndex.value * lineHeight.value + containerHeight.value * 0.2 + lineHeight.value; // 加上顶部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: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
// 实际播放时间
|
||||
const actualTime = ref(0);
|
||||
|
||||
// 计算当前行的进度
|
||||
const currentProgress = computed(() => {
|
||||
const { startCurrentTime, nextTime } = dynamicData.value;
|
||||
if (!startCurrentTime || !nextTime) 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);
|
||||
};
|
||||
|
||||
// 记录上次更新时间
|
||||
|
||||
// 监听据更新
|
||||
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;
|
||||
lrcArray: Array<{ text: string; trText: string }>;
|
||||
lrcTimeArray: number[];
|
||||
allTime: number;
|
||||
playMusic: SongResult;
|
||||
}) => {
|
||||
// 确保数据存在且格式正确
|
||||
if (!parsedData) {
|
||||
console.error('Invalid update data received:', parsedData);
|
||||
return;
|
||||
}
|
||||
// 更新静态数据
|
||||
staticData.value = {
|
||||
lrcArray: parsedData.lrcArray || [],
|
||||
lrcTimeArray: parsedData.lrcTimeArray || [],
|
||||
allTime: parsedData.allTime || 0,
|
||||
playMusic: parsedData.playMusic || {},
|
||||
};
|
||||
|
||||
// 更新动态数据
|
||||
dynamicData.value = {
|
||||
nowTime: parsedData.nowTime || 0,
|
||||
startCurrentTime: parsedData.startCurrentTime || 0,
|
||||
nextTime: parsedData.nextTime || 0,
|
||||
isPlay: parsedData.isPlay,
|
||||
};
|
||||
|
||||
// 更新索引
|
||||
if (typeof parsedData.nowIndex === 'number') {
|
||||
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 {
|
||||
lyricData.value = JSON.parse(data);
|
||||
const parsedData = JSON.parse(data);
|
||||
handleDataUpdate(parsedData);
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
console.error('Error parsing lyric data:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateContainerHeight);
|
||||
});
|
||||
|
||||
const checkTheme = () => {
|
||||
if (lyricSetting.value.theme === 'light') {
|
||||
lyricSetting.value.theme = 'dark';
|
||||
@@ -103,34 +446,106 @@ const checkTheme = () => {
|
||||
|
||||
const handleTop = () => {
|
||||
lyricSetting.value.isTop = !lyricSetting.value.isTop;
|
||||
ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
||||
windowData.electron.ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
||||
};
|
||||
|
||||
const handleLock = () => {
|
||||
lyricSetting.value.isLock = !lyricSetting.value.isLock;
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', lyricSetting.value.isLock);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
ipcRenderer.send('close-lyric');
|
||||
windowData.electron.ipcRenderer.send('close-lyric');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => lyricSetting.value,
|
||||
(newValue) => {
|
||||
(newValue: any) => {
|
||||
localStorage.setItem('lyricData', JSON.stringify(newValue));
|
||||
},
|
||||
{ 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');
|
||||
// });
|
||||
// });
|
||||
// 添<EFBFBD><EFBFBD>拖动相关变量
|
||||
const isDragging = ref(false);
|
||||
const startPosition = ref({ x: 0, y: 0 });
|
||||
|
||||
// 处理鼠标按下事件
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// 如果点击的是控制按钮区域或窗口被锁定,不处理拖动
|
||||
if (
|
||||
lyricSetting.value.isLock ||
|
||||
(e.target as HTMLElement).closest('.control-buttons') ||
|
||||
(e.target as HTMLElement).closest('.font-size-controls')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只响应鼠标左键
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isDragging.value = true;
|
||||
startPosition.value = { x: e.screenX, y: e.screenY };
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const deltaX = e.screenX - startPosition.value.x;
|
||||
const deltaY = e.screenY - startPosition.value.y;
|
||||
|
||||
// 发送移动事件到主进程
|
||||
windowData.electron.ipcRenderer.send('lyric-drag-move', { deltaX, deltaY });
|
||||
startPosition.value = { x: e.screenX, y: e.screenY };
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
|
||||
// 移除事件监听
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 添加全局事件监听
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
isDragging.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const lyricLock = document.getElementById('lyric-lock');
|
||||
if (lyricLock) {
|
||||
lyricLock.onmouseenter = () => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
|
||||
}
|
||||
};
|
||||
lyricLock.onmouseleave = () => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', true);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 添加播放控制相关的函数
|
||||
const handlePlayPause = () => {
|
||||
windowData.electron.ipcRenderer.send('control-back', 'playpause');
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
windowData.electron.ipcRenderer.send('control-back', 'prev');
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
windowData.electron.ipcRenderer.send('control-back', 'next');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -143,132 +558,240 @@ body {
|
||||
.lyric-window {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@apply overflow-hidden text-gray-600 rounded-xl box-border;
|
||||
// border: 4px solid transparent;
|
||||
&:hover .lyric-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover .drag-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 10px 0 rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
.control-bar {
|
||||
&-show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
--text-color: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--highlight-color: #1db954;
|
||||
--control-bg: rgba(124, 124, 124, 0.3);
|
||||
}
|
||||
|
||||
&.light {
|
||||
--text-color: #333333;
|
||||
--text-secondary: rgba(51, 51, 51, 0.6);
|
||||
--highlight-color: #1db954;
|
||||
--control-bg: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
padding: 0 20px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
z-index: 100;
|
||||
|
||||
.font-size-controls {
|
||||
-webkit-app-region: no-drag;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.play-controls {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.play-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
i {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
&:hover {
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&.active {
|
||||
color: var(--highlight-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-container {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&.lyric-line-passed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
.control-buttons {
|
||||
.control-button {
|
||||
&:not(:has(.ri-lock-line)):not(:has(.ri-lock-unlock-line)) {
|
||||
.lyric_lock & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric_lock & .font-size-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lyric_lock & .play-controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric_lock {
|
||||
background: transparent;
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
&:hover .lyric-bar {
|
||||
background-color: transparent;
|
||||
.button {
|
||||
opacity: 0;
|
||||
}
|
||||
.button-lock {
|
||||
opacity: 1;
|
||||
color: #d6d6d6;
|
||||
}
|
||||
}
|
||||
&:hover .drag-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-xl hover:text-white;
|
||||
}
|
||||
|
||||
.lyric-bar {
|
||||
background-color: #b1b1b1;
|
||||
@apply flex flex-col justify-center items-center;
|
||||
width: 100vw;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.lyric-bar-hover {
|
||||
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;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@apply flex items-center;
|
||||
|
||||
> div {
|
||||
@apply cursor-pointer;
|
||||
#lyric-lock {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 72px;
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
&-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>
|
||||
|
||||
@@ -3,24 +3,33 @@
|
||||
<div class="mv-list-title">
|
||||
<h2>推荐MV</h2>
|
||||
</div>
|
||||
<n-scrollbar :size="100">
|
||||
<div class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
|
||||
<div class="play-list-type">
|
||||
<n-scrollbar x-scrollable>
|
||||
<div class="categories-wrapper">
|
||||
<span
|
||||
v-for="(category, index) in categories"
|
||||
:key="category.value"
|
||||
class="play-list-type-item"
|
||||
:class="[setAnimationClass('animate__bounceIn'), { active: selectedCategory === category.value }]"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="selectedCategory = category.value"
|
||||
>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<n-scrollbar :size="100" @scroll="handleScroll">
|
||||
<div v-loading="initLoading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
|
||||
<div
|
||||
v-for="(item, index) in mvList"
|
||||
:key="item.id"
|
||||
class="mv-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
:style="getAnimationDelay(index)"
|
||||
>
|
||||
<div class="mv-item-img" @click="handleShowMv(item)">
|
||||
<n-image
|
||||
class="mv-item-img-img"
|
||||
:src="getImgUrl(item.cover, '200y112')"
|
||||
lazy
|
||||
preview-disabled
|
||||
width="200"
|
||||
height="112"
|
||||
/>
|
||||
<div class="mv-item-img" @click="handleShowMv(item, index)">
|
||||
<n-image class="mv-item-img-img" :src="getImgUrl(item.cover, '320y180')" lazy preview-disabled />
|
||||
<div class="top">
|
||||
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
|
||||
<i class="iconfont icon-videofill"></i>
|
||||
@@ -28,28 +37,29 @@
|
||||
</div>
|
||||
<div class="mv-item-title">{{ item.name }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMore" class="loading-more">加载中...</div>
|
||||
<div v-if="!hasMore && !initLoading" class="no-more">没有更多了</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
|
||||
<n-drawer :show="showMv" height="100vh" placement="bottom" :z-index="999999999">
|
||||
<div class="mv-detail">
|
||||
<video :src="playMvUrl" controls autoplay></video>
|
||||
<div class="mv-detail-title">
|
||||
<div class="title">{{ playMvItem?.name }}</div>
|
||||
<button @click="close">
|
||||
<i class="iconfont icon-xiasanjiaoxing"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer>
|
||||
<mv-player
|
||||
v-model:show="showMv"
|
||||
:current-mv="playMvItem"
|
||||
:is-prev-disabled="isPrevDisabled"
|
||||
@next="playNextMv"
|
||||
@prev="playPrevMv"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMvUrl, getTopMv } from '@/api/mv';
|
||||
import { getAllMv, getTopMv } from '@/api/mv';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
@@ -60,42 +70,159 @@ defineOptions({
|
||||
const showMv = ref(false);
|
||||
const mvList = ref<Array<IMvItem>>([]);
|
||||
const playMvItem = ref<IMvItem>();
|
||||
const playMvUrl = ref<string>();
|
||||
const store = useStore();
|
||||
const initLoading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = ref(42);
|
||||
const hasMore = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getTopMv(30);
|
||||
mvList.value = res.data.data;
|
||||
const categories = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '内地', value: '内地' },
|
||||
{ label: '港台', value: '港台' },
|
||||
{ label: '欧美', value: '欧美' },
|
||||
{ label: '日本', value: '日本' },
|
||||
{ label: '韩国', value: '韩国' },
|
||||
];
|
||||
const selectedCategory = ref('全部');
|
||||
|
||||
watch(selectedCategory, async () => {
|
||||
offset.value = 0;
|
||||
mvList.value = [];
|
||||
hasMore.value = true;
|
||||
await loadMvList();
|
||||
});
|
||||
|
||||
const handleShowMv = async (item: IMvItem) => {
|
||||
const getAnimationDelay = computed(() => {
|
||||
return (index: number) => setAnimationDelay(index, 30);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMvList();
|
||||
});
|
||||
|
||||
const handleShowMv = async (item: IMvItem, index: number) => {
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showMv.value = true;
|
||||
const res = await getMvUrl(item.id);
|
||||
currentIndex.value = index;
|
||||
playMvItem.value = item;
|
||||
playMvUrl.value = res.data.data.url;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
showMv.value = false;
|
||||
if (store.state.playMusicUrl) {
|
||||
store.commit('setIsPlay', true);
|
||||
const playPrevMv = async (setLoading: (value: boolean) => void) => {
|
||||
try {
|
||||
if (currentIndex.value > 0) {
|
||||
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 () => {
|
||||
try {
|
||||
if (!hasMore.value || loadingMore.value) return;
|
||||
if (offset.value === 0) {
|
||||
initLoading.value = true;
|
||||
} else {
|
||||
loadingMore.value = true;
|
||||
}
|
||||
|
||||
const params = {
|
||||
limit: limit.value,
|
||||
offset: offset.value,
|
||||
area: selectedCategory.value === '全部' ? '' : selectedCategory.value,
|
||||
};
|
||||
|
||||
const res = selectedCategory.value === '全部' ? await getTopMv(params) : await getAllMv(params);
|
||||
|
||||
const { data } = res.data;
|
||||
mvList.value.push(...data);
|
||||
hasMore.value = data.length === limit.value;
|
||||
offset.value += limit.value;
|
||||
} 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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mv-list {
|
||||
@apply relative h-full w-full px-4;
|
||||
@apply h-full flex-1 flex flex-col overflow-hidden;
|
||||
|
||||
&-title {
|
||||
@apply text-xl font-bold;
|
||||
@apply text-xl font-bold pb-2;
|
||||
}
|
||||
|
||||
// 添加歌单分类样式
|
||||
.play-list-type {
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4;
|
||||
}
|
||||
|
||||
.categories-wrapper {
|
||||
@apply flex items-center py-2 pb-4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply py-2 px-3 mr-3 inline-block border border-gray-700 rounded-xl cursor-pointer transition-all duration-300;
|
||||
background-color: #1a1a1a;
|
||||
|
||||
&:hover {
|
||||
@apply bg-green-600/50;
|
||||
}
|
||||
|
||||
&.active {
|
||||
@apply bg-green-600 border-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply grid gap-6 pb-28 mt-2;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14%, 1fr));
|
||||
@apply grid gap-4 pb-28 mt-2 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.mv-item {
|
||||
@@ -103,6 +230,7 @@ const close = () => {
|
||||
background-color: #1f1f1f;
|
||||
&-img {
|
||||
@apply rounded-lg overflow-hidden relative;
|
||||
aspect-ratio: 16/9;
|
||||
line-height: 0;
|
||||
|
||||
&:hover img {
|
||||
@@ -110,7 +238,7 @@ const close = () => {
|
||||
}
|
||||
|
||||
&-img {
|
||||
@apply w-full rounded-lg overflow-hidden;
|
||||
@apply w-full h-full object-cover rounded-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.top {
|
||||
@@ -147,36 +275,19 @@ 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;
|
||||
}
|
||||
.mobile {
|
||||
.mv-list-title {
|
||||
@apply text-xl font-bold px-4;
|
||||
}
|
||||
|
||||
video {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
video:hover + .mv-detail-title {
|
||||
@apply top-0;
|
||||
}
|
||||
|
||||
.mv-detail-title:hover {
|
||||
@apply top-0;
|
||||
.mv-list-content {
|
||||
@apply px-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.mv-list-content {
|
||||
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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
:style="setAnimationDelay(index, 10)"
|
||||
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>
|
||||
{{ item.searchWord }}
|
||||
@@ -29,32 +29,30 @@
|
||||
:native-scrollbar="false"
|
||||
>
|
||||
<div class="title">{{ hotKeyword }}</div>
|
||||
<n-spin :show="searchDetailLoading">
|
||||
<div class="search-list-box">
|
||||
<template v-if="searchDetail">
|
||||
<div
|
||||
v-for="(item, index) in searchDetail?.songs"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<song-item :item="item" @play="handlePlay" />
|
||||
</div>
|
||||
<template v-for="(list, key) in searchDetail">
|
||||
<template v-if="key.toString() !== 'songs'">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<SearchItem :item="item" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="searchDetailLoading" class="search-list-box">
|
||||
<template v-if="searchDetail">
|
||||
<div
|
||||
v-for="(item, index) in searchDetail?.songs"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<song-item :item="item" @play="handlePlay" />
|
||||
</div>
|
||||
<template v-for="(list, key) in searchDetail">
|
||||
<template v-if="key.toString() !== 'songs'">
|
||||
<div
|
||||
v-for="(item, index) in list"
|
||||
:key="item.id"
|
||||
:class="setAnimationClass('animate__bounceInRight')"
|
||||
:style="setAnimationDelay(index, 50)"
|
||||
>
|
||||
<SearchItem :item="item" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
</div>
|
||||
</template>
|
||||
@@ -62,7 +60,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useDateFormat } from '@vueuse/core';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getHotSearch } from '@/api/home';
|
||||
@@ -76,9 +74,10 @@ defineOptions({
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const searchDetail = ref<any>();
|
||||
const searchType = ref(Number(route.query.type) || 1);
|
||||
const searchType = computed(() => store.state.searchType as number);
|
||||
const searchDetailLoading = ref(false);
|
||||
|
||||
// 热搜列表
|
||||
@@ -90,29 +89,26 @@ const loadHotSearch = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadHotSearch();
|
||||
loadSearch(route.query.keyword);
|
||||
});
|
||||
|
||||
const hotKeyword = ref(route.query.keyword || '搜索列表');
|
||||
const clickHotKeyword = (keyword: string) => {
|
||||
hotKeyword.value = keyword;
|
||||
router.push({
|
||||
path: '/search',
|
||||
query: {
|
||||
keyword,
|
||||
type: 1,
|
||||
},
|
||||
});
|
||||
// isHotSearchList.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => store.state.searchValue,
|
||||
(value) => {
|
||||
loadSearch(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;
|
||||
searchDetail.value = undefined;
|
||||
if (!keywords) return;
|
||||
|
||||
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 albums = data.result.albums || [];
|
||||
@@ -135,7 +131,6 @@ const loadSearch = async (keywords: any) => {
|
||||
// songs map 替换属性
|
||||
songs.forEach((item: any) => {
|
||||
item.picUrl = item.al.picUrl;
|
||||
item.song = item;
|
||||
item.artists = item.ar;
|
||||
});
|
||||
albums.forEach((item: any) => {
|
||||
@@ -151,18 +146,15 @@ const loadSearch = async (keywords: any) => {
|
||||
searchDetailLoading.value = false;
|
||||
};
|
||||
|
||||
loadSearch(route.query.keyword);
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
async (newParams) => {
|
||||
searchType.value = Number(newParams.type || 1);
|
||||
loadSearch(newParams.keyword);
|
||||
() => route.path,
|
||||
async (path) => {
|
||||
if (path === '/search') {
|
||||
store.state.searchValue = route.query.keyword;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const handlePlay = () => {
|
||||
const tracks = searchDetail.value?.songs || [];
|
||||
store.commit('setPlayList', tracks);
|
||||
|
||||
@@ -1,55 +1,128 @@
|
||||
<template>
|
||||
<div class="set-page">
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">代理</div>
|
||||
<div class="set-item-content">无法听音乐时打开</div>
|
||||
<n-scrollbar>
|
||||
<div class="set-page" :class="setAnimationClass('animate__bounceInLeft')">
|
||||
<div v-if="isElectron" class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">代理</div>
|
||||
<div class="set-item-content">无法听音乐时打开</div>
|
||||
</div>
|
||||
<n-switch v-model:value="setData.isProxy" />
|
||||
</div>
|
||||
<n-switch v-model:value="setData.isProxy" />
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">版本</div>
|
||||
<div class="set-item-content">当前已是最新版本</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">关闭动画效果</div>
|
||||
</div>
|
||||
<n-switch v-model:value="setData.noAnimate" />
|
||||
</div>
|
||||
<div>{{ setData.version }}</div>
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">作者</div>
|
||||
<div class="set-item-content"></div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">动画速度</div>
|
||||
<div class="set-item-content">调节动画播放速度</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||||
<div class="w-40">
|
||||
<n-slider
|
||||
v-model:value="setData.animationSpeed"
|
||||
:min="0.1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:marks="{
|
||||
0.1: '极慢',
|
||||
1: '正常',
|
||||
3: '极快',
|
||||
}"
|
||||
:disabled="setData.noAnimate"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">版本</div>
|
||||
<div class="set-item-content">当前已是最新版本</div>
|
||||
</div>
|
||||
<div>{{ config.version }}</div>
|
||||
</div>
|
||||
<div class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all" @click="openAuthor">
|
||||
<div>
|
||||
<div class="set-item-title">作者</div>
|
||||
<div class="set-item-content">algerkong github</div>
|
||||
</div>
|
||||
<div>{{ setData.author }}</div>
|
||||
</div>
|
||||
<div>{{ setData.author }}</div>
|
||||
</div>
|
||||
|
||||
<div class="set-action">
|
||||
<n-button @click="handelCancel">取消</n-button>
|
||||
<n-button type="primary" @click="handleSave">保存并重启</n-button>
|
||||
<div class="set-action">
|
||||
<n-button class="w-40 h-10" @click="handelCancel">取消</n-button>
|
||||
<n-button type="primary" class="w-40 h-10" @click="handleSave">{{
|
||||
isElectron ? '保存并重启' : '保存'
|
||||
}}</n-button>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<p class="text-sm text-gray-100 text-center cursor-pointer hover:text-green-500" @click="copyQQ">
|
||||
QQ群:789288579
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6 bg-black rounded-lg shadow-lg">
|
||||
<div class="text-gray-100 text-base text-center">支持作者</div>
|
||||
<div class="flex gap-60">
|
||||
<div class="flex flex-col items-center gap-2 cursor-none hover:scale-[2] transition-all z-10 bg-black">
|
||||
<n-image :src="alipay" alt="支付宝收款码" class="w-32 h-32 rounded-lg" preview-disabled />
|
||||
<span class="text-sm text-gray-100">支付宝</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2 cursor-none hover:scale-[2] transition-all z-10 bg-black">
|
||||
<n-image :src="wechat" alt="微信收款码" class="w-32 h-32 rounded-lg" preview-disabled />
|
||||
<span class="text-sm text-gray-100">微信支付</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import config from '@/../package.json';
|
||||
import alipay from '@/assets/alipay.png';
|
||||
import wechat from '@/assets/wechat.png';
|
||||
import store from '@/store';
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Setting',
|
||||
});
|
||||
const message = useMessage();
|
||||
const copyQQ = () => {
|
||||
navigator.clipboard.writeText('789288579');
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
const setData = ref(store.state.setData);
|
||||
const isElectron = ref((window as any).electronAPI !== undefined);
|
||||
const router = useRouter();
|
||||
|
||||
// 使用计算属性来获取和设置数据
|
||||
const setData = computed({
|
||||
get: () => store.state.setData,
|
||||
set: (value) => store.commit('setSetData', value),
|
||||
});
|
||||
|
||||
const handelCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const windowData = window as any;
|
||||
|
||||
const handleSave = () => {
|
||||
store.commit('setSetData', setData.value);
|
||||
windowData.electronAPI.restart();
|
||||
if (isElectron.value) {
|
||||
(window as any).electronAPI.restart();
|
||||
}
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const openAuthor = () => {
|
||||
window.open(setData.value.authorUrl, '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -58,7 +131,7 @@ const handleSave = () => {
|
||||
@apply flex flex-col justify-center items-center pt-8;
|
||||
}
|
||||
.set-item {
|
||||
@apply w-3/5 flex justify-between items-center mb-4;
|
||||
@apply w-3/5 flex justify-between items-center mb-2 px-4 py-2 rounded-lg;
|
||||
.set-item-title {
|
||||
@apply text-gray-200 text-base;
|
||||
}
|
||||
@@ -66,4 +139,7 @@ const handleSave = () => {
|
||||
@apply text-gray-400 text-sm;
|
||||
}
|
||||
}
|
||||
.set-action {
|
||||
@apply flex gap-3 mt-4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ const router = useRouter();
|
||||
const userDetail = ref<IUserDetail>();
|
||||
const playList = ref<any[]>([]);
|
||||
const recordList = ref();
|
||||
const infoLoading = ref(false);
|
||||
|
||||
const user = computed(() => store.state.user);
|
||||
|
||||
@@ -29,6 +30,7 @@ const loadPage = async () => {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
infoLoading.value = true;
|
||||
|
||||
const { data: userData } = await getUserDetail(user.value.userId);
|
||||
userDetail.value = userData;
|
||||
@@ -36,9 +38,13 @@ const loadPage = async () => {
|
||||
const { data: playlistData } = await getUserPlaylist(user.value.userId);
|
||||
playList.value = playlistData.playlist;
|
||||
|
||||
getUserRecord(user.value.userId).then(({ data: recordData }) => {
|
||||
recordList.value = recordData.allData;
|
||||
});
|
||||
const { data: recordData } = await getUserRecord(user.value.userId);
|
||||
recordList.value = recordData.allData.map((item: any) => ({
|
||||
...item,
|
||||
...item.song,
|
||||
picUrl: item.song.al.picUrl,
|
||||
}));
|
||||
infoLoading.value = false;
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
@@ -51,27 +57,20 @@ onActivated(() => {
|
||||
|
||||
const isShowList = ref(false);
|
||||
const list = ref<Playlist>();
|
||||
const listLoading = ref(false);
|
||||
// 展示歌单
|
||||
const showPlaylist = async (id: number) => {
|
||||
const showPlaylist = async (id: number, name: string) => {
|
||||
isShowList.value = true;
|
||||
list.value = {};
|
||||
listLoading.value = true;
|
||||
|
||||
list.value = {
|
||||
name,
|
||||
} as Playlist;
|
||||
const { data } = await getListDetail(id);
|
||||
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 tracks = recordList.value || [];
|
||||
store.commit('setPlayList', tracks);
|
||||
@@ -108,9 +107,14 @@ const handlePlay = () => {
|
||||
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
|
||||
|
||||
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
|
||||
<div class=" ">创建的歌单</div>
|
||||
<div class="title">创建的歌单</div>
|
||||
<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 />
|
||||
<div class="play-list-item-info">
|
||||
<div class="play-list-item-name">{{ item.name }}</div>
|
||||
@@ -122,25 +126,31 @@ const handlePlay = () => {
|
||||
</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="record-list">
|
||||
<n-scrollbar>
|
||||
<div
|
||||
v-for="(item, index) in recordList"
|
||||
:key="item.song.id"
|
||||
:key="item.id"
|
||||
class="record-item"
|
||||
: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>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
<music-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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
content: ['./src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
extend: {
|
||||
colors: {
|
||||
highlight: 'var(--highlight-color)',
|
||||
text: 'var(--text-color)',
|
||||
secondary: 'var(--text-secondary)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -33,18 +33,23 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
// 指定端口
|
||||
port: 4678,
|
||||
port: 4488,
|
||||
proxy: {
|
||||
// with options
|
||||
'/api': {
|
||||
target: 'http://110.42.251.190:9898',
|
||||
[process.env.VITE_API_LOCAL as string]: {
|
||||
target: process.env.VITE_API,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_LOCAL}`), ''),
|
||||
},
|
||||
'/music': {
|
||||
target: 'http://110.42.251.190:4100',
|
||||
[process.env.VITE_API_MUSIC_PROXY as string]: {
|
||||
target: process.env.VITE_API_MUSIC,
|
||||
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