Compare commits

..

37 Commits

Author SHA1 Message Date
alger
abdb2bcd50 feat: 新增主题色切换功能 默认为日间主题可切换夜间 (#19、#21)
fixes #19  #21
2024-12-28 16:43:52 +08:00
alger
f728191a8f feat: 顶栏修改 2024-12-27 18:27:01 +08:00
alger
dfa8b51a53 📃 docs: qq 2024-12-25 19:59:58 +08:00
alger
b2c13121fd feat: 添加mv分类 2024-12-25 19:55:24 +08:00
alger
d28adb61a4 feat: 优化 list 加载 2024-12-17 23:23:20 +08:00
alger
9a7d5a3834 feat: 记忆歌词窗口位置 主窗口可关闭歌词窗口 2024-12-16 22:25:38 +08:00
alger
2037798fbe feat: 修复桌面歌词滚动问题 2024-12-16 22:15:25 +08:00
alger
85bd0ad015 feat: 优化桌面歌词添加歌曲控制 上一首下一首 播放暂停 2024-12-16 22:12:28 +08:00
alger
e1557a51a3 feat: 优化下载应用功能 去除web 窗口样式 2024-12-16 20:40:57 +08:00
alger
1ecc6f136f feat: 添加网页端可拖动边缘调整窗口大小功能 2024-12-15 21:17:35 +08:00
alger
53b3061b03 feat: 优化歌单列表页面 添加分类 2024-12-15 18:19:58 +08:00
alger
3d2f6a2330 feat: 将收藏与历史合并 2024-12-15 15:12:45 +08:00
alger
3b1470f28f feat: 添加设置菜单 优化移动端菜单显示 2024-12-15 14:35:18 +08:00
alger
100268448a feat: 优化图片加载 2024-12-15 14:13:13 +08:00
alger
51f67bb2c2 feat: 优化应用下载 2024-12-15 13:00:20 +08:00
alger
7be126cf5f feat: 优化播放器样式 添加单曲循环 优化桌面歌词效果 2024-12-15 01:40:13 +08:00
alger
f2f5d3ac15 feat: 优化web端页面效果 展示为 pc应用样式 2024-12-14 13:49:32 +08:00
alger
34c45e0105 📃 docs: 修该注释 2024-12-14 13:15:59 +08:00
alger
f9333f5f78 🐞 fix: 修复搜索时 使用空格导致的空格快捷键冲突问题(#18)
fixes #18
2024-12-14 13:00:06 +08:00
alger
7365daf700 🐞 fix: 修复播放暂停控制问题 后续优化为参数监听 2024-12-12 22:36:07 +08:00
alger
cebf313075 feat: 优化播放 修改为howler 修复搜索导致播放无限卡顿问题(#15)
- 优化了整个项目的播放
- 去除audio
- 优化歌词页 歌词同步时间

fixes #15
2024-12-12 22:18:52 +08:00
alger
bb99049991 feat: 优化页面效果 2024-12-09 22:58:57 +08:00
alger
df74dafbc5 feat: 优化歌单列表页面 2024-12-09 22:39:33 +08:00
alger
721d2a9704 feat: 添加搜藏功能 与页面 2024-12-09 21:55:08 +08:00
alger
1e60fa9a95 feat: 添加展开收起歌词的提示 2024-12-09 20:51:40 +08:00
alger
f24e8232f8 feat: 修复布局问题 2024-12-09 20:39:32 +08:00
alger
a1b1d861ac feat: 修改下载地址 2024-12-09 18:43:05 +08:00
alger
f24263b416 🐞 fix: 修复滚动问题 2024-12-08 21:57:34 +08:00
alger
17795e5da2 feat: 添加动画速度调整功能 优化页面自适应效果 2024-12-08 21:50:58 +08:00
alger
f1030d3a78 feat: seo 优化 2024-12-08 21:35:15 +08:00
alger
b979ce250f feat: 添加 Coffee 2024-12-07 23:20:31 +08:00
alger
d0d8966875 feat: 优化移动端 歌词与歌单页面显示 2024-12-07 22:54:45 +08:00
alger
d39ba65263 📃 docs: 修改文档 2024-12-07 22:38:56 +08:00
alger
62d400827e 📃 docs: 修改文档 2024-12-07 22:33:36 +08:00
alger
75b99c46b5 📃 docs: 修改文档 2024-12-07 22:32:06 +08:00
alger
e7ae79144c feat: 修改登录背景 2024-12-07 21:50:18 +08:00
alger
04d6cbe7f3 🐞 fix: 修复二维码登录 重复触发请求问题 修改为手机号优先 2024-12-07 21:37:10 +08:00
54 changed files with 2798 additions and 1326 deletions

View File

@@ -1,5 +1,5 @@
# 你的接口地址 (必填)
VITE_API = ***
VITE_API_LOCAL = ***
# 音乐破解接口地址
VITE_API_MUSIC = ***
# 代理地址

View File

@@ -39,6 +39,7 @@
]
},
"rules": {
"no-nested-ternary": "off",
"no-console": "off",
"no-continue": "off",
"no-restricted-syntax": "off",

View File

@@ -7,6 +7,8 @@
- 播放历史
- 桌面歌词
- 歌单 mv 搜索 专辑等功能
- 识别无法播放歌曲 并代理播放
- 可听周杰伦(搜索专辑)
## 项目简介
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
@@ -14,17 +16,12 @@
## 预览地址
[http://mc.alger.fun/](http://mc.alger.fun/)
## Stargazers over time
[![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer)
QQ群:789288579
## 软件截图
![首页](./docs/img/image-7.png)
![歌词](./docs/img/image-6.png)
![歌单](./docs/img/image-1.png)
![搜索](./docs/img/image-8.png)
![mv](./docs/img/image-3.png)
![历史](./docs/img/image-4.png)
![我的](./docs/img/image-5.png)
## 技术栈
@@ -44,6 +41,11 @@
- 多平台支持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
# 安装依赖
@@ -68,28 +70,32 @@
```bash
# .env.development
# 你的接口地址 (必填)
VITE_API = ***
# 音乐破解接口地址
VITE_API_MUSIC = ***
# 代理地址
VITE_API_PROXY = ***
# 本地运行代理地址
VITE_API_PROXY = /api
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
[![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer)
## 欢迎提Issues
## 免责声明

2
app.js
View File

@@ -66,7 +66,7 @@ function createWindow() {
store.set('set', setJson);
}
loadLyricWindow(ipcMain);
loadLyricWindow(ipcMain, mainWin);
}
// 限制只能启动一个应用

6
components.d.ts vendored
View File

@@ -7,6 +7,7 @@ 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']
@@ -14,17 +15,22 @@ declare module 'vue' {
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']
NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']

View File

@@ -1,17 +1,34 @@
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: false,
contextIsolation: true,
@@ -19,16 +36,26 @@ const createWin = () => {
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' });
@@ -39,26 +66,39 @@ const loadLyricWindow = (ipcMain) => {
}
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', () => {
@@ -68,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 = {

View File

@@ -1,5 +1,7 @@
{
"version": "1.5.1",
"isProxy": false,
"author": "alger"
"noAnimate": false,
"animationSpeed": 1,
"author": "Alger",
"authorUrl": "https://github.com/algerkong"
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "alger-music",
"version": "2.1.0",
"version": "2.5.0",
"description": "这是一个用于音乐播放的应用程序。",
"author": "Alger <algerkc@qq.com>",
"main": "app.js",
@@ -17,7 +17,9 @@
"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",
@@ -47,7 +49,7 @@
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"remixicon": "^4.2.0",
"sass": "^1.78.0",
"sass": "^1.82.0",
"tailwindcss": "^3.4.15",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.2",

View File

@@ -1,29 +1,37 @@
<template>
<div class="app-container" :class="{ mobile: isMobile }">
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
<n-config-provider :theme="darkTheme">
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
<n-config-provider :theme="theme === 'dark' ? darkTheme : lightTheme">
<n-dialog-provider>
<router-view></router-view>
<n-message-provider>
<router-view></router-view>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</div>
</template>
<script setup lang="ts">
import { darkTheme } from 'naive-ui';
import { darkTheme, lightTheme } 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;
const theme = computed(() => {
return store.state.theme;
});
onMounted(() => {
if (windowData.electron) {
const setData = windowData.electron.ipcRenderer.getStoreValue('set');
store.commit('setSetData', setData);
store.dispatch('initializeSettings');
store.dispatch('initializeTheme');
if (isMobile.value) {
store.commit(
'setMenus',
homeRouter.filter((item) => item.meta.isMobile),
);
}
});
</script>

View File

@@ -2,15 +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 = 30, offset = 0) => {
export const getTopMv = (params: MvParams) => {
return request({
url: '/mv/all',
method: 'get',
params: {
limit,
offset,
},
params,
});
};
// 获取所有mv
export const getAllMv = (params: MvParams) => {
return request({
url: '/mv/all',
method: 'get',
params,
});
};

BIN
src/assets/alipay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
src/assets/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

59
src/components/Coffee.vue Normal file
View File

@@ -0,0 +1,59 @@
<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 rounded-lg shadow-lg bg-light dark:bg-gray-800">
<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-700 dark:text-gray-200">支付宝</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-700 dark:text-gray-200">微信支付</span>
</div>
</div>
<div class="mt-4">
<p
class="text-sm text-gray-700 dark:text-gray-200 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>

View File

@@ -1,70 +1,114 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100vh' : '70vh'"
:height="isMobile ? '100%' : '80%'"
placement="bottom"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
:to="`#layout-main`"
@mask-click="close"
>
<div class="music-page">
<div class="music-close">
<i class="icon iconfont icon-icon_error" @click="close"></i>
<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-title text-el">{{ name }}</div>
<!-- 歌单歌曲列表 -->
<div v-loading="loading" class="music-list">
<n-virtual-list
v-if="displayedSongs.length"
ref="virtualListRef"
:items="displayedSongs"
:item-size="60"
:keep-alive="true"
:min-size="5"
:style="{ height: listHeight }"
@scroll="handleScroll"
>
<template #default="{ item }">
<song-item :item="formatDetail(item)" @play="handlePlay" />
</template>
</n-virtual-list>
<div v-else-if="loading" class="loading-more">加载中...</div>
<play-bottom />
<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>
<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 />
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
// 导入 NVirtualListInst 类型
import type { VirtualListInst } from 'naive-ui';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { isMobile } from '@/utils';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const store = useStore();
const props = defineProps<{
show: boolean;
name: string;
songList: any[];
loading?: boolean;
listInfo?: {
trackIds: { id: number }[];
[key: string]: any;
};
}>();
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,
},
);
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(() => {
@@ -133,11 +177,14 @@ const loadMoreSongs = async () => {
console.error('加载歌曲失败:', error);
} finally {
isLoadingMore.value = false;
loadingList.value = false;
}
};
// 添加虚拟列表的引用
const virtualListRef = ref<VirtualListInst | null>(null);
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 20);
};
// 修改滚动处理函数
const handleScroll = (e: Event) => {
@@ -150,6 +197,16 @@ const handleScroll = (e: Event) => {
}
};
watch(
() => props.show,
(newVal) => {
loadingList.value = newVal;
if (!props.cover) {
loadingList.value = false;
}
},
);
// 监听 songList 变化,重置分页状态
watch(
() => props.songList,
@@ -159,38 +216,69 @@ watch(
if (newSongs.length > pageSize) {
page.value = 1;
}
loadingList.value = false;
},
{ immediate: true },
);
// 添加计算属性来处理列表高度
const listHeight = computed(() => {
const baseHeight = '100%'; // 减去标题高度
return store.state.isPlay ? `calc(100% - 90px)` : baseHeight; // 112px 是 PlayBottom 的高度
});
</script>
<style scoped lang="scss">
.music {
&-title {
@apply text-xl font-bold text-gray-900 dark:text-white;
}
&-page {
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark: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 flex gap-2 items-center;
@apply cursor-pointer text-gray-900 dark: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-2xl 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-gray-700 dark:text-gray-300;
}
}
.music-desc {
@apply text-sm text-gray-600 dark:text-gray-400 leading-relaxed;
}
}
}
&-list {
height: calc(100% - 60px);
position: relative; // 添加相对定位
@apply flex-grow min-h-0;
&-container {
@apply flex-grow min-h-0 flex flex-col relative;
}
&-content {
@apply min-h-[calc(80vh-60px)];
}
:deep(.n-virtual-list__scroll) {
scrollbar-width: none;
@@ -205,27 +293,28 @@ const listHeight = computed(() => {
.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;
@apply text-center py-4 text-gray-500 dark:text-gray-400;
}
.double-list {
.double-item {
width: 100%;
}
.song-item {
background-color: #191919;
}
}
// 确保 PlayBottom 不会影响滚动区域
:deep(.bottom) {
position: absolute;
bottom: 0;
left: 0;
right: 0;
.double-item {
@apply mb-2 bg-light-100 bg-opacity-20 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<n-drawer :show="show" height="100vh" placement="bottom" :z-index="999999999">
<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
@@ -90,7 +90,7 @@
</div>
<div class="right-controls">
<div class="volume-control custom-slider">
<div v-if="!isMobile" class="volume-control custom-slider">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleMute">
@@ -172,7 +172,7 @@
<script setup lang="ts">
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { getMvUrl } from '@/api/mv';
@@ -300,6 +300,7 @@ onUnmounted(() => {
if (cursorTimer) {
clearTimeout(cursorTimer);
}
unlockScreenOrientation();
});
// 监听 currentMv 的变化
@@ -390,7 +391,28 @@ const checkFullscreenAPI = () => {
};
};
// 切换全屏状态
// 添加横屏锁定功能
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();
@@ -403,9 +425,17 @@ const toggleFullscreen = async () => {
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);
@@ -513,194 +543,63 @@ watch(showControls, (newValue) => {
resetCursorTimer();
}
});
const isMobile = computed(() => store.state.isMobile);
</script>
<style scoped lang="scss">
.mv-detail {
@apply w-full h-full bg-black relative;
@apply h-full bg-light dark:bg-black;
.video-container {
@apply w-full h-full relative;
transition: cursor 0.3s ease;
&-title {
@apply fixed top-0 left-0 right-0 p-4 z-10 transition-opacity duration-300;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent);
&.cursor-hidden {
* {
cursor: none !important;
}
.title {
@apply text-white text-lg font-bold;
}
}
}
// 控制栏区域保持鼠标可见
.custom-controls {
* {
cursor: default !important;
}
.video-container {
@apply h-full w-full relative;
.video-player {
@apply h-full w-full object-contain bg-black;
}
.play-hint {
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50;
.n-button {
@apply text-white hover:text-green-500 transition-colors;
}
}
.custom-controls {
@apply absolute bottom-0 left-0 right-0 p-4 transition-opacity duration-300;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
.controls-main {
@apply flex justify-between items-center;
.left-controls,
.right-controls {
@apply flex items-center gap-2;
.n-button {
cursor: pointer !important;
}
.n-slider {
cursor: pointer !important;
}
}
}
&:fullscreen,
&:-webkit-full-screen,
&:-moz-full-screen,
&:-ms-fullscreen {
background: black;
width: 100vw;
height: 100vh;
// 确保全屏时标题栏正确显示
.mv-detail-title {
@apply px-8 py-6;
.title {
@apply text-xl;
}
}
// 确保全屏时控制栏正确显示
.custom-controls {
padding: 20px 24px;
}
}
&::after {
content: '';
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
pointer-events: none;
}
&:active::after {
@apply opacity-10;
}
video {
@apply w-full h-full;
}
.custom-controls {
@apply absolute bottom-0 left-0 w-full transition-opacity duration-300 ease-in-out;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
padding: 16px 20px;
&.controls-hidden {
opacity: 0;
pointer-events: none;
}
.progress-bar {
@apply mb-4;
.progress-rail {
@apply relative w-full h-full;
.progress-buffer {
@apply absolute h-full bg-gray-600 rounded-full;
transition: width 0.2s ease;
}
}
}
.controls-main {
@apply flex justify-between items-center;
.left-controls,
.right-controls {
@apply flex items-center gap-4;
@apply text-white hover:text-green-500 transition-colors;
}
.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;
@apply text-white text-sm ml-4;
}
}
}
}
.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;
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center;
.mode-icon {
@apply text-white mb-2;
@@ -711,7 +610,49 @@ watch(showControls, (newValue) => {
}
}
// 添加过渡动画
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: rgba(255, 255, 255, 0.2);
--n-fill-color: #10b981;
--n-handle-size: 12px;
--n-handle-color: #10b981;
}
}
.progress-bar {
@apply mb-4;
.progress-rail {
@apply relative w-full h-1 bg-gray-600;
.progress-buffer {
@apply absolute top-0 left-0 h-full bg-gray-400;
}
}
}
.volume-control {
@apply flex items-center gap-2;
.volume-slider {
width: 100px;
}
}
.controls-hidden {
opacity: 0;
pointer-events: none;
}
.cursor-hidden {
cursor: none;
}
.title-hidden {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
@@ -721,79 +662,4 @@ watch(showControls, (newValue) => {
.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>

View File

@@ -132,15 +132,15 @@ onMounted(() => {
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.play-list-type {
width: 250px;
@apply mx-6;
&-item,
&-showall {
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 transition;
background-color: #1a1a1a;
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-200 dark:border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 hover:text-white transition;
}
&-showall {
@apply block text-center;

View File

@@ -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(() => {
@@ -61,7 +81,7 @@ onMounted(() => {
.recommend-album {
@apply flex-1 mx-5;
.title {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.recommend-album-list {
@@ -75,7 +95,7 @@ onMounted(() => {
filter: brightness(50%);
}
&-content {
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl bg-opacity-60 bg-black;
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl text-white bg-opacity-60 bg-black dark:bg-opacity-60 dark:bg-black;
}
&-content:hover {
opacity: 1;

View File

@@ -53,6 +53,7 @@
v-model:show="showMusic"
name="每日推荐列表"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
</div>
</n-scrollbar>
@@ -89,7 +90,6 @@ const loadData = async () => {
const {
data: { data: dayRecommend },
} = await getDayRecommend();
console.log('dayRecommend', dayRecommend);
// 处理数据
if (dayRecommend) {
singerData.artists = singerData.artists.slice(0, 4);
@@ -131,14 +131,20 @@ watchEffect(() => {
&-item {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between overflow-hidden;
&-bg {
@apply bg-gray-900 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(60%);
}
&-info {
@apply flex items-center p-2;
&-play {
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer;
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer text-white;
}
&-name {
@apply text-gray-100 dark:text-gray-100;
}
}
&-count {
@apply text-gray-100 dark:text-gray-100;
}
}
}

View File

@@ -51,17 +51,15 @@ const handlePlay = () => {
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.recommend-music {
@apply flex-auto;
// width: 530px;
.text-ellipsis {
width: 100%;
}
&-list {
@apply rounded-3xl p-2 w-full border border-gray-700;
background-color: #0d0d0d;
@apply rounded-3xl p-2 w-full border border-gray-200 dark:border-gray-700 bg-light dark:bg-black;
}
}
</style>

View File

@@ -6,14 +6,24 @@
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">Alger Music</h2>
<p class="app-desc">在桌面安装应用获得更好的体验</p>
<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>
@@ -21,39 +31,23 @@
<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;
localStorage.setItem('installPromptDismissed', 'true');
};
const handleInstall = async () => {
// 新页面打开
// 识别当前环境是 mac 还是 windows
const os = navigator.platform;
const isMac = os.includes('Mac');
const isWindows = os.includes('Win');
const urls = {
mac: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.dmg',
windows: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.exe',
};
// 根据操作系统选择下载链接
let downloadUrl = '';
if (isMac) {
downloadUrl = urls.mac;
} else if (isWindows) {
downloadUrl = urls.windows;
}
if (downloadUrl) {
window.open(downloadUrl, '_blank');
if (noPrompt.value) {
localStorage.setItem('installPromptDismissed', 'true');
}
};
onMounted(() => {
// 如果是 electron 环境,不显示安装提示
if (isElectron.value) {
if (isElectron.value || isMobile.value) {
return;
}
@@ -64,6 +58,29 @@ onMounted(() => {
}
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>
@@ -72,11 +89,11 @@ onMounted(() => {
@apply max-w-sm;
}
.modal-content {
@apply p-4;
@apply p-4 pb-0;
.modal-header {
@apply flex items-center mb-6;
.app-icon {
@apply w-16 h-16 mr-4 rounded-2xl overflow-hidden;
@apply w-20 h-20 mr-4 rounded-2xl overflow-hidden;
img {
@apply w-full h-full object-cover;
}
@@ -92,7 +109,7 @@ onMounted(() => {
}
}
.modal-actions {
@apply flex gap-3;
@apply flex gap-3 mt-4;
.n-button {
@apply flex-1;
}

View File

@@ -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();
}
},
);

View File

@@ -27,6 +27,7 @@ import { useStore } from 'vuex';
import { getAlbum, getListDetail } from '@/api/list';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils';
@@ -75,6 +76,7 @@ const handleClick = async () => {
if (props.item.type === 'mv') {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showPop.value = true;
}
};

View File

@@ -35,11 +35,11 @@
</template>
</div>
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
<div class="song-item-operating-like">
<i class="iconfont icon-likefill"></i>
<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"
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)"
>
@@ -51,9 +51,10 @@
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
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';
@@ -63,10 +64,12 @@ const props = withDefaults(
item: SongResult;
mini?: boolean;
list?: boolean;
favorite?: boolean;
}>(),
{
mini: false,
list: false,
favorite: true,
},
);
@@ -103,8 +106,10 @@ 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;
}
@@ -112,6 +117,21 @@ const playMusicEvent = async (item: SongResult) => {
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>
@@ -119,69 +139,98 @@ const playMusicEvent = async (item: SongResult) => {
.text-ellipsis {
width: 100%;
}
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center hover:bg-gray-800 transition;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
&-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
&-content {
@apply flex-1;
&-title {
@apply text-base text-white;
@apply text-base text-gray-900 dark:text-white;
}
&-name {
@apply text-xs;
@apply text-gray-400;
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
&-operating {
@apply flex items-center pl-4 rounded-full border border-gray-700 ml-4;
background-color: #0d0d0d;
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
color: #868686;
@apply text-xl hover:text-red-600 transition;
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer;
@apply mr-2 cursor-pointer ml-4;
}
.like-active {
@apply text-red-500;
}
&-play {
@apply cursor-pointer border border-gray-500 rounded-full w-10 h-10 flex justify-center items-center hover:bg-green-600 transition;
animation-iteration-count: infinite;
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
}
.song-mini {
@apply p-2 rounded-2xl;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1;
@apply mr-1 ml-1;
}
&-play {
@apply w-8 h-8;
}
@@ -190,35 +239,50 @@ const playMusicEvent = async (item: SongResult) => {
}
.song-list {
@apply p-2 rounded-lg hover:bg-gray-800/50 border border-gray-800/50 mb-2;
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.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%];
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-400;
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply text-gray-400 flex-1 min-w-0;
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.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;
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}

View File

@@ -1,7 +1,7 @@
<!-- -->
<template>
<div v-if="isShow" class="loading-box">
<div class="mask" :style="{ background: maskBackground }"></div>
<div class="mask"></div>
<div class="loading-content-box">
<n-spin size="small" />
<div :style="{ color: textColor }" class="tip">{{ tip }}</div>
@@ -23,7 +23,7 @@ defineProps({
maskBackground: {
type: String,
default() {
return 'rgba(0, 0, 0, 0.8)';
return 'rgba(0, 0, 0, 0.05)';
},
},
loadingColor: {
@@ -70,6 +70,7 @@ defineExpose({
.mask {
width: 100%;
height: 100%;
@apply bg-light-100 bg-opacity-50 dark:bg-dark-100 dark:bg-opacity-50;
}
.loading-content-box {
position: absolute;

View File

@@ -1,7 +1,9 @@
import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import store from '@/store';
import type { ILyricText, SongResult } from '@/type/music';
import { getTextColors } from '@/utils/linearColor';
const windowData = window as any;
@@ -14,21 +16,116 @@ export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词
export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const audio = ref<HTMLAudioElement>(); // 音频对象
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound());
export const isLyricWindowOpen = ref(false); // 新增状态
export const textColors = ref(getTextColors());
document.onkeyup = (e) => {
// 检查事件目标是否是输入框元素
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
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:
}
};
watch(
() => store.state.playMusicUrl,
(newVal) => {
if (newVal) {
audioService.play(newVal);
sound.value = audioService.getCurrentSound();
audioServiceOn(audioService);
}
},
);
watch(
() => store.state.playMusic,
() => {
nextTick(() => {
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);
// 增加矫正时间
@@ -78,25 +175,18 @@ export const getLrcStyle = (index: number) => {
return {};
};
watch(nowTime, (newTime) => {
const newIndex = getLrcIndex(newTime);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0; // 重置进度
}
});
// 播放进度
export const useLyricProgress = () => {
let animationFrameId: number | null = null;
const updateProgress = () => {
if (!isPlaying.value) return;
audio.value = audio.value || (document.querySelector('#MusicAudio') as HTMLAudioElement);
if (!audio.value) return;
const currentSound = sound.value;
if (!currentSound) return;
const { start, end } = currentLrcTiming.value;
const duration = end - start;
const elapsed = audio.value.currentTime - start;
const elapsed = (currentSound.seek() as number) - start;
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
animationFrameId = requestAnimationFrame(updateProgress);
@@ -139,10 +229,13 @@ export const useLyricProgress = () => {
};
};
// 设置前播放时间
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index];
audio.play();
// 设置<EFBFBD><EFBFBD><EFBFBD>前播放时间
export const setAudioTime = (index: number) => {
const currentSound = sound.value;
if (!currentSound) return;
currentSound.seek(lrcTimeArray.value[index]);
currentSound.play();
};
// 获取当前播放的歌词
@@ -154,7 +247,7 @@ export const getCurrentLrc = () => {
};
};
// 获取一句歌词播放时间几秒到几秒
// 获取一句歌词播放时间几秒到几秒
export const getLrcTimeRange = (index: number) => ({
currentTime: lrcTimeArray.value[index],
nextTime: lrcTimeArray.value[index + 1],
@@ -164,89 +257,33 @@ export const getLrcTimeRange = (index: number) => ({
watch(
() => lrcArray.value,
(newLrcArray) => {
if (newLrcArray.length > 0 && isElectron.value) {
// 重新初始化歌词数据
initLyricWindow();
// 发送当前状态
if (newLrcArray.length > 0 && isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin();
}
},
);
// 监听播放状态变化
watch(isPlaying, (newIsPlaying) => {
if (isElectron.value) {
sendLyricToWin(newIsPlaying);
}
});
// 监听时间变化
watch(nowTime, (newTime) => {
const newIndex = getLrcIndex(newTime);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0; // 重置进度
// 当索引变化时发送更新
if (isElectron.value) {
sendLyricToWin();
}
}
});
// 处理歌曲结束
export const handleEnded = () => {
// ... 原有的结束处理逻辑 ...
// 如果有歌词窗口,发送初始化数据
if (isElectron.value) {
// 延迟一下等待新歌曲加载完成
setTimeout(() => {
initLyricWindow();
sendLyricToWin();
}, 100);
}
};
// 初始化歌词数据
export const initLyricWindow = () => {
if (!isElectron.value) return;
try {
if (lrcArray.value.length > 0) {
console.log('Initializing lyric window with data:', {
lrcArray: lrcArray.value,
lrcTimeArray: lrcTimeArray.value,
allTime: allTime.value,
});
const staticData = {
type: 'init',
lrcArray: lrcArray.value,
lrcTimeArray: lrcTimeArray.value,
allTime: allTime.value,
};
windowData.electronAPI.sendLyric(JSON.stringify(staticData));
} else {
console.log('No lyrics available for initialization');
}
} catch (error) {
console.error('Error initializing lyric window:', error);
}
};
// 发送歌词更新数据
export const sendLyricToWin = (isPlay: boolean = true) => {
if (!isElectron.value) return;
export const sendLyricToWin = () => {
if (!isElectron.value || !isLyricWindowOpen.value) {
console.log('Cannot send lyric: electron or lyric window not available');
return;
}
try {
if (lrcArray.value.length > 0) {
const nowIndex = getLrcIndex(nowTime.value);
const updateData = {
type: 'update',
type: 'full',
nowIndex,
nowTime: nowTime.value,
startCurrentTime: lrcTimeArray.value[nowIndex],
nextTime: lrcTimeArray.value[nowIndex + 1],
isPlay,
isPlay: isPlaying.value,
lrcArray: lrcArray.value,
lrcTimeArray: lrcTimeArray.value,
allTime: allTime.value,
playMusic: playMusic.value,
};
windowData.electronAPI.sendLyric(JSON.stringify(updateData));
}
@@ -257,13 +294,52 @@ export const sendLyricToWin = (isPlay: boolean = true) => {
export const openLyric = () => {
if (!isElectron.value) return;
console.log('Opening lyric window');
windowData.electronAPI.openLyric();
console.log('Opening lyric window with current song:', playMusic.value?.name);
// 延迟一下初始化,确保窗口已经创建
setTimeout(() => {
console.log('Initializing lyric window after delay');
initLyricWindow();
isLyricWindowOpen.value = !isLyricWindowOpen.value;
if (isLyricWindowOpen.value) {
setTimeout(() => {
windowData.electronAPI.openLyric();
sendLyricToWin();
}, 500);
sendLyricToWin();
}, 500);
} 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;
}
});
}

View File

@@ -1,5 +1,8 @@
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';
@@ -53,9 +56,13 @@ export const useMusicListHook = () => {
// 用于预加载下一首歌曲的 MP3 数据
const preloadNextSong = (nextSongUrl: string) => {
const audio = new Audio(nextSongUrl);
audio.preload = 'auto'; // 设置预加载
audio.load(); // 手动加载
const sound = new Howl({
src: [nextSongUrl],
html5: true,
preload: true,
autoplay: false,
});
return sound;
};
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
@@ -166,9 +173,19 @@ export const useMusicListHook = () => {
state.playMusic.lyric = lyrics;
};
const play = () => {
audioService.getCurrentSound()?.play();
};
const pause = () => {
audioService.getCurrentSound()?.pause();
};
return {
handlePlayMusic,
nextPlay,
prevPlay,
play,
pause,
};
};

View File

@@ -10,6 +10,51 @@
width: 100%;
}
.n-slider-handle-indicator--top {
@apply bg-transparent dark:text-[#ffffffdd] text-[#000000dd] text-2xl px-2 py-1 shadow-none mb-0 !important;
}
.text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
}
.theme-dark {
--bg-color: #000;
--text-color: #fff;
--bg-color-100: #161616;
--bg-color-200: #2d2d2d;
--bg-color-300: #3d3d3d;
--text-color: #f8f9fa;
--text-color-100: #e9ecef;
--text-color-200: #dee2e6;
--text-color-300: #dde0e3;
--primary-color: #22c55e;
}
.theme-light {
--bg-color: #fff;
--text-color: #000;
--bg-color-100: #f8f9fa;
--bg-color-200: #e9ecef;
--bg-color-300: #dee2e6;
--text-color: #000;
--text-color-100: #161616;
--text-color-200: #2d2d2d;
--text-color-300: #3d3d3d;
--primary-color: #22c55e;
}
.theme-gray {
--bg-color: #f8f9fa;
--text-color: #000;
--bg-color-100: #e9ecef;
--bg-color-200: #dee2e6;
--bg-color-300: #dde0e3;
--text-color: #000;
--text-color-100: #161616;
--text-color-200: #2d2d2d;
--text-color-300: #3d3d3d;
--primary-color: #22c55e;
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="layout-page">
<div class="layout-main" :style="{ background: backgroundColor }">
<div id="layout-main" class="layout-main">
<title-bar v-if="isElectron" />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
<!-- 侧边菜单栏 -->
@@ -10,17 +10,15 @@
<search-bar />
<!-- 主页面路由 -->
<div class="main-content" :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>
<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" />
@@ -34,6 +32,7 @@
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
@@ -49,8 +48,6 @@ const keepAliveInclude = computed(() =>
return item.meta.keepAlive;
})
.map((item) => {
// return item.name;
// 首字母大写
return item.name.charAt(0).toUpperCase() + item.name.slice(1);
}),
);
@@ -64,102 +61,35 @@ 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 backgroundColor = ref('#000');
// watch(
// () => store.state.playMusic,
// () => {
// backgroundColor.value = store.state.playMusic.backgroundColor;
// console.log('backgroundColor.value', backgroundColor.value);
// },
// {
// immediate: true,
// deep: true,
// },
// );
onMounted(() => {
// 监听音乐是否播放
watch(
() => play.value,
(value) => {
if (value && audio.value) {
audioPlay();
} else {
audioPause();
}
},
);
document.onkeyup = (e) => {
switch (e.code) {
case 'Space':
playMusicEvent();
break;
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);
}
};
</script>
<style lang="scss" scoped>
.layout-page {
width: 100vw;
height: 100vh;
@apply flex justify-center items-center overflow-hidden bg-black;
@apply w-screen h-screen overflow-hidden bg-light dark:bg-black;
}
.layout-main {
@apply text-white shadow-xl flex flex-col relative transition-all;
height: 100%;
width: 100%;
overflow: hidden;
&-page {
@apply flex flex-1 overflow-hidden;
}
.main {
@apply flex-1 box-border flex flex-col overflow-hidden;
height: 100%;
&-content {
@apply box-border flex-1 overflow-hidden;
}
}
// :deep(.n-scrollbar-content) {
// @apply pr-3;
// }
@apply w-full h-full relative text-gray-900 dark:text-white;
}
.mobile {
.layout-main {
&-page {
@apply pt-4;
}
}
.layout-main-page {
@apply flex h-full;
}
.menu {
@apply h-full;
}
.main {
@apply overflow-hidden flex-1 flex flex-col;
}
.main-content {
@apply flex-1 overflow-hidden;
}
.main-page {
@apply h-full;
}
</style>

View File

@@ -65,9 +65,10 @@ const iconStyle = (index: number) => {
<style lang="scss" scoped>
.app-menu {
@apply flex-col items-center justify-center px-6;
@apply flex-col items-center justify-center px-6 bg-light dark:bg-black;
max-width: 100px;
}
.app-menu-item-link,
.app-menu-header {
@apply flex items-center justify-center;
@@ -77,10 +78,12 @@ const iconStyle = (index: number) => {
@apply mb-6 mt-6;
}
.app-menu-item-icon:hover {
color: #10b981 !important;
transform: scale(1.05);
transition: 0.2s ease-in-out;
.app-menu-item-icon {
@apply transition-all duration-200 text-gray-500 dark:text-gray-400;
&:hover {
@apply text-green-500 scale-105;
}
}
.mobile {
@@ -89,13 +92,16 @@ const iconStyle = (index: number) => {
width: 100vw;
position: relative;
z-index: 999999;
background-color: #000;
@apply bg-light dark:bg-black border-t border-gray-200 dark:border-gray-700;
&-header {
display: none;
}
&-list {
@apply flex justify-between;
}
&-item {
&-link {
@apply my-4;

View File

@@ -1,13 +1,14 @@
<template>
<n-drawer
:show="musicFull"
height="100vh"
height="100%"
placement="bottom"
:style="{ background: currentBackground || background }"
:to="`#layout-main`"
>
<div id="drawer-target">
<div class="drawer-back"></div>
<div class="music-img">
<div class="music-img" :style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }">
<n-image ref="PicImgRef" :src="getImgUrl(playMusic?.picUrl, '300y300')" class="img" lazy preview-disabled />
<div>
<div class="music-content-name">{{ playMusic.name }}</div>
@@ -34,7 +35,7 @@
:key="index"
class="music-lrc-text"
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@click="setAudioTime(index, audio)"
@click="setAudioTime(index)"
>
<span :style="getLrcStyle(index)">{{ item.text }}</span>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
@@ -55,7 +56,7 @@
import { useDebounceFn } from '@vueuse/core';
import { onBeforeUnmount, ref, watch } from 'vue';
import { lrcArray, nowIndex, playMusic, setAudioTime, useLyricProgress } from '@/hooks/MusicHook';
import { lrcArray, nowIndex, playMusic, setAudioTime, textColors, useLyricProgress } from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
@@ -67,18 +68,11 @@ 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: '',
@@ -258,10 +252,9 @@ defineExpose({
background-color: transparent;
span {
padding-right: 100px;
// display: inline-block;
background-clip: text !important;
-webkit-background-clip: text !important;
padding-right: 30px;
}
&-tr {
@@ -286,12 +279,19 @@ 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;
}
}
}

View File

@@ -1,23 +1,34 @@
<template>
<!-- 展开全屏 -->
<music-full
ref="MusicFullRef"
v-model:music-full="musicFullVisible"
:audio="audio.value as HTMLAudioElement"
:background="background"
/>
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
<!-- 底部播放栏 -->
<div
class="music-play-bar"
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
:style="{
color: musicFullVisible
? textColors.theme === 'dark'
? '#000000'
: '#ffffff'
: store.state.theme === 'dark'
? '#ffffff'
: '#000000',
}"
>
<n-image
:src="getImgUrl(playMusic?.picUrl, '300y300')"
class="play-bar-img"
lazy
preview-disabled
@click="setMusicFull"
/>
<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">
@@ -40,37 +51,38 @@
<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 custom-slider">
<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 custom-slider">
<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">
<template #trigger>
<i class="iconfont icon-Play" @click="parsingMusic"></i>
</template>
解析播放
</n-tooltip> -->
</n-tooltip>
<n-tooltip v-if="isElectron" 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>
@@ -109,11 +121,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, getCurrentLrc, isElectron, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
import { allTime, isElectron, isLyricWindowOpen, nowTime, openLyric, sound, textColors } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
@@ -125,12 +138,8 @@ 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(
@@ -141,62 +150,70 @@ watch(
{ 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');
}
@@ -206,27 +223,17 @@ function handlePrev() {
const MusicFullRef = ref<any>(null);
function handleGetAudioTime(this: HTMLAudioElement) {
// 监听音频播放的实时时间事件
const audio = this as HTMLAudioElement;
// 获取当前播放时间
nowTime.value = audio.currentTime;
getCurrentLrc();
// 获取总时间
allTime.value = audio.duration;
// 获取音量
audioVolume.value = audio.volume;
sendLyricToWin(store.state.isPlay);
// if (musicFullVisible.value) {
// 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);
}
};
@@ -246,6 +253,23 @@ const scrollToPlayList = (val: boolean) => {
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>
<style lang="scss" scoped>
@@ -254,27 +278,27 @@ const scrollToPlayList = (val: boolean) => {
}
.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;
@apply bg-light dark:bg-dark shadow-2xl shadow-gray-300;
z-index: 9999;
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
background-color: #212121;
animation-duration: 0.5s !important;
.music-content {
width: 140px;
width: 160px;
@apply ml-4;
&-title {
@apply text-base text-white;
@apply text-base;
}
&-name {
@apply text-xs mt-1 text-gray-100;
@apply text-xs mt-1 opacity-80;
}
}
}
.play-bar-opcity {
@apply bg-transparent;
@apply bg-transparent !important;
box-shadow: 0 0 20px 5px #0000001d;
}
@@ -283,14 +307,16 @@ const scrollToPlayList = (val: boolean) => {
}
.music-buttons {
@apply mx-6;
@apply mx-6 flex-1 flex justify-center;
.iconfont {
@apply text-2xl hover:text-green-500 transition;
@apply text-2xl transition;
@apply hover:text-green-500;
}
.icon {
@apply text-xl hover:text-white;
@apply text-3xl;
@apply hover:text-green-500;
}
@apply flex items-center;
@@ -300,25 +326,31 @@ const scrollToPlayList = (val: boolean) => {
}
&-play {
background: #383838;
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition bg-opacity-40;
}
}
.music-time {
@apply flex flex-1 items-center;
.time {
@apply mx-4 mt-1;
@apply flex justify-center items-center w-20 h-12 rounded-full mx-4 transition text-gray-500;
@apply bg-gray-100 bg-opacity-60 hover:bg-gray-200;
}
}
.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;
@apply text-2xl transition;
@apply hover:text-green-500;
}
.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 rounded-xl;
@apply bg-light dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
}
}
@@ -326,7 +358,8 @@ const scrollToPlayList = (val: boolean) => {
@apply flex items-center mx-4;
.iconfont {
@apply text-2xl hover:text-green-500 transition cursor-pointer m-4;
@apply text-2xl transition cursor-pointer m-4;
@apply hover:text-green-500;
}
}
@@ -337,7 +370,8 @@ const scrollToPlayList = (val: boolean) => {
@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;
@apply absolute top-0 left-0 w-full h-full;
@apply bg-light dark:bg-black bg-opacity-75;
}
&-content {
@apply mx-2;
@@ -377,26 +411,42 @@ const scrollToPlayList = (val: boolean) => {
}
}
// 添加自定义 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-rail-color: theme('colors.gray.200');
--n-rail-color-dark: theme('colors.gray.700');
--n-fill-color: theme('colors.green.500');
--n-handle-size: 12px;
--n-handle-color: var(--primary-color);
--n-handle-color: theme('colors.green.500');
&:hover {
--n-rail-height: 6px;
--n-handle-size: 14px;
&.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;
@apply overflow-hidden transition-all duration-200;
@apply bg-gray-500 dark:bg-dark-300 bg-opacity-10 !important;
}
.n-slider-handle {
@apply transition-opacity duration-200;
@apply transition-all duration-200;
opacity: 0;
}
@@ -406,7 +456,55 @@ const scrollToPlayList = (val: boolean) => {
}
}
: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-500 hover:text-red-600 !important;
}
.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>

View File

@@ -6,54 +6,92 @@
size="medium"
round
:placeholder="hotSearchKeyword"
class="border border-gray-600"
class="border dark:border-gray-600 border-gray-200"
@keydown.enter="search"
>
<template #prefix>
<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 === store.state.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-tooltip v-if="!isElectron">
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
<template #trigger>
<div class="github" @click="toGithub">
<i class="ri-github-fill"></i>
<div class="user-box">
<n-avatar
v-if="store.state.user"
class="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>前往 Github</div>
</n-tooltip>
<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">
<i class="iconfont" :class="isDarkTheme ? 'ri-moon-line' : 'ri-sun-line'"></i>
<span>主题</span>
<n-switch v-model:value="isDarkTheme" class="ml-auto">
<template #checked>
<i class="ri-moon-line"></i>
</template>
<template #unchecked>
<i class="ri-sun-line"></i>
</template>
</n-switch>
</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>
<script lang="ts" setup>
import { onMounted, ref, watchEffect } from 'vue';
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 { isElectron } from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
const router = useRouter();
@@ -97,6 +135,11 @@ onMounted(() => {
loadPage();
});
const isDarkTheme = computed({
get: () => store.state.theme === 'dark',
set: () => store.commit('toggleTheme'),
});
// 搜索词
const searchValue = ref('');
const search = () => {
@@ -141,6 +184,9 @@ const selectItem = async (key: string) => {
case 'set':
router.push('/set');
break;
case 'user':
router.push('/user');
break;
default:
}
};
@@ -148,18 +194,37 @@ const selectItem = async (key: string) => {
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;
background: #1a1a1a;
@apply ml-4 flex text-lg justify-center items-center rounded-full transition-colors duration-200;
@apply border dark:border-gray-600 border-gray-200 hover:border-gray-400 dark:hover:border-gray-400;
@apply bg-light dark:bg-gray-800;
}
.search-box {
@apply pb-4 pr-4;
}
.search-box-input {
@apply relative;
:deep(.n-input) {
@apply bg-gray-50 dark:bg-black;
.n-input__input-el {
@apply text-gray-900 dark:text-white;
}
.n-input__prefix {
@apply text-gray-500 dark:text-gray-400;
}
}
}
.mobile {
@@ -169,6 +234,45 @@ const toGithub = () => {
}
.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;
@apply cursor-pointer text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 text-xl ml-4 rounded-full flex justify-center items-center px-2 h-full;
@apply border dark:border-gray-600 border-gray-200 bg-light dark:bg-black;
}
.user-popover {
@apply min-w-[280px] p-0 rounded-xl overflow-hidden;
@apply bg-light dark:bg-black;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.user-header {
@apply flex items-center gap-2 p-3 cursor-pointer;
@apply border-b dark:border-gray-700 border-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700;
.username {
@apply text-sm font-medium text-gray-900 dark: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-700 dark:text-gray-300;
transition: background-color 0.2s;
&:hover {
@apply bg-gray-100 dark:bg-gray-700;
}
i {
@apply mr-1 text-lg text-gray-500 dark:text-gray-400;
}
.download-btn {
@apply ml-auto text-xs px-2 py-0.5 rounded;
@apply bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300;
}
}
}
}
</style>

View File

@@ -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>
@@ -45,7 +56,8 @@ const drag = (event: MouseEvent) => {
<style scoped lang="scss">
#title-bar {
-webkit-app-region: drag;
@apply flex justify-between text-white px-6 py-2 select-none relative;
@apply flex justify-between px-6 py-2 select-none relative;
@apply text-dark dark:text-white;
z-index: 9999999;
}
@@ -55,6 +67,6 @@ const drag = (event: MouseEvent) => {
}
button {
@apply hover:text-green-500;
@apply text-gray-600 dark:text-gray-400 hover:text-green-500;
}
</style>

View File

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

View 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();

View File

@@ -3,6 +3,21 @@ import { createStore } from 'vuex';
import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
import type { SongResult } from '@/type/music';
import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
// 默认设置
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 +33,9 @@ interface State {
isMobile: boolean;
searchValue: string;
searchType: number;
favoriteList: number[];
playMode: number;
theme: ThemeType;
}
const state: State = {
@@ -26,14 +44,17 @@ 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),
theme: getCurrentTheme(),
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
@@ -61,17 +82,63 @@ const mutations = {
async prevPlay(state: State) {
await prevPlay(state);
},
async setSetData(state: State, setData: any) {
setSetData(state: State, setData: any) {
state.setData = setData;
if ((window as any).electron) {
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));
},
toggleTheme(state: State) {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
applyTheme(state.theme);
},
};
const actions = {
initializeSettings({ commit }: { commit: any }) {
const isElectron = (window as any).electronAPI !== undefined;
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);
}
}
},
initializeTheme({ state }: { state: State }) {
applyTheme(state.theme);
},
};
const store = createStore({
state,
mutations,
actions,
});
export default store;

View File

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

View File

@@ -11,11 +11,20 @@ export const setAnimationClass = (type: String) => {
if (store.state.setData && store.state.setData.noAnimate) {
return '';
}
return `animate__animated ${type}`;
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`;
};
// 将秒转换为分钟和秒

View File

@@ -186,6 +186,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
interface ITextColors {
primary: string;
active: string;
theme: string;
}
// 添加新的函数
@@ -230,6 +231,7 @@ export const getTextColors = (gradient: string = ''): ITextColors => {
return {
primary: isDark ? 'rgba(0, 0, 0, 0.54)' : 'rgba(255, 255, 255, 0.54)',
active: isDark ? '#000000' : '#ffffff',
theme: isDark ? 'dark' : 'light',
};
};

19
src/utils/theme.ts Normal file
View File

@@ -0,0 +1,19 @@
export type ThemeType = 'dark' | 'light';
// 应用主题
export const applyTheme = (theme: ThemeType) => {
// 使用 Tailwind 的暗色主题类
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// 保存主题到本地存储
localStorage.setItem('theme', theme);
};
// 获取当前主题
export const getCurrentTheme = (): ThemeType => {
return (localStorage.getItem('theme') as ThemeType) || 'light';
};

View File

@@ -0,0 +1,217 @@
<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;
@apply bg-light dark:bg-black;
.favorite-header {
@apply flex items-center justify-between flex-shrink-0 px-4;
h2 {
@apply text-xl font-bold pb-2;
@apply text-gray-900 dark:text-white;
}
.favorite-count {
@apply text-gray-500 dark: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;
@apply text-gray-500 dark:text-gray-400;
}
.favorite-list {
@apply space-y-2 pb-4 px-4;
&-more {
@apply mt-4;
.n-button {
@apply text-green-500 hover:text-green-600;
}
}
}
}
}
}
.loading-wrapper {
@apply flex justify-center items-center py-20;
}
.no-more-tip {
@apply text-center py-4 text-sm;
@apply text-gray-500 dark:text-gray-400;
}
.mobile {
.favorite-page {
@apply p-4;
.favorite-header {
@apply mb-4;
h2 {
@apply text-xl;
}
}
}
}
</style>

View File

@@ -1,32 +1,41 @@
<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" list @play="handlePlay" />
<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({
@@ -35,21 +44,96 @@ defineOptions({
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', musicList.value);
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;
@apply bg-light dark:bg-black;
.title {
@apply pl-4 text-xl font-bold;
@apply pl-4 text-xl font-bold pb-2 px-4;
@apply text-gray-900 dark:text-white;
}
.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 {
@@ -57,11 +141,24 @@ const handlePlay = () => {
}
&-count {
@apply px-4 text-lg text-center;
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
}
}
}
}
.loading-wrapper {
@apply flex justify-center items-center py-8;
}
.no-more-tip {
@apply text-center py-4 text-sm;
@apply text-gray-500 dark:text-gray-400;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex gap-4 h-full pb-4">
<favorite class="flex-item" />
<history class="flex-item" />
</div>
</template>
<script setup lang="ts">
import Favorite from '@/views/favorite/index.vue';
import History from '@/views/history/index.vue';
</script>
<style scoped>
.flex-item {
@apply flex-1 bg-light-100 dark:bg-dark-100 rounded-2xl overflow-hidden;
}
</style>

View File

@@ -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',
});
@@ -29,13 +29,31 @@ defineOptions({
<style lang="scss" scoped>
.main-page {
@apply h-full w-full overflow-hidden;
@apply h-full w-full overflow-hidden bg-light dark:bg-black;
}
.main-content {
@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 text-gray-900 dark:text-white;
}
}
.favorite-list {
@apply px-0 !important;
}
}
</style>

View File

@@ -1,138 +1,22 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { getListByCat, getListDetail, getRecommendList } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'List',
});
const ITEMS_PER_ROW = ref(6); // 每行显示的数量
const TOTAL_ITEMS = 30; // 每页数量
// 计算实际需要加载的数量,确保能被每行数量整除
const getAdjustedLimit = (perRow: number) => {
return Math.ceil(TOTAL_ITEMS / perRow) * perRow;
};
const recommendList = ref<any[]>([]);
const showMusic = ref(false);
const page = ref(0);
const hasMore = ref(true);
const isLoadingMore = ref(false);
// 计算每个项目在当前页面中的索引
const getItemAnimationDelay = (index: number) => {
const adjustedLimit = getAdjustedLimit(ITEMS_PER_ROW.value);
const currentPageIndex = index % adjustedLimit;
return setAnimationDelay(currentPageIndex, 30);
};
const recommendItem = ref<IRecommendItem | null>();
const 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 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 adjustedLimit = getAdjustedLimit(ITEMS_PER_ROW.value);
const params = {
cat: type || '',
limit: adjustedLimit,
offset: page.value * adjustedLimit,
};
const { data } = await getListByCat(params);
if (isLoadMore) {
recommendList.value.push(...data.playlists);
} else {
recommendList.value = data.playlists;
}
hasMore.value = data.more;
page.value++;
} catch (error) {
console.error('加载歌单列表失败:', error);
} finally {
loading.value = false;
isLoadingMore.value = false;
}
};
// 监听滚动事件
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 updateItemsPerRow = () => {
const width = window.innerWidth;
if (width > 1800) ITEMS_PER_ROW.value = 8;
else if (width > 1200) ITEMS_PER_ROW.value = 8;
else if (width > 768) ITEMS_PER_ROW.value = 6;
else ITEMS_PER_ROW.value = 5;
};
onMounted(() => {
updateItemsPerRow();
window.addEventListener('resize', updateItemsPerRow);
if (route.query.type) {
loadList(route.query.type as string);
} else {
getRecommendList(getAdjustedLimit(ITEMS_PER_ROW.value)).then((res: { data: { result: any } }) => {
recommendList.value = res.data.result;
});
}
});
onUnmounted(() => {
window.removeEventListener('resize', updateItemsPerRow);
});
watch(
() => route.query,
async (newParams) => {
if (newParams.type) {
recommendList.value = [];
listTitle.value = newParams.type || '歌单列表';
loadList(newParams.type as string);
}
},
);
</script>
<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" @scroll="handleScroll">
<div v-loading="loading" class="recommend-list">
@@ -178,66 +62,258 @@ watch(
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
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 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 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;
}
};
// 监听滚动事件
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 = [];
listTitle.value = newParams.type || '歌单列表';
currentType.value = newParams.type as string;
loading.value = true;
loadList(newParams.type as string);
}
},
);
</script>
<style lang="scss" scoped>
.list-page {
@apply relative h-full w-full;
@apply bg-light dark:bg-black;
}
.recommend {
@apply w-full h-full bg-none;
@apply w-full h-full;
&-title {
@apply text-lg font-bold text-white pb-4;
@apply text-lg font-bold pb-2;
@apply text-gray-900 dark:text-white;
}
&-list {
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
grid-template-columns: repeat(v-bind(ITEMS_PER_ROW), minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
&-item {
@apply flex flex-col;
&-img {
@apply rounded-xl overflow-hidden relative w-full;
@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;
}
.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;
@apply bg-black bg-opacity-50;
opacity: 0;
i {
font-size: 50px;
transition: all 0.5s ease-in-out;
opacity: 0;
@apply text-5xl text-white transition-all duration-500 ease-in-out opacity-0;
}
&:hover {
@apply opacity-100;
}
&:hover i {
@apply transform scale-150 opacity-100;
}
.play-count {
@apply absolute top-2 left-2 text-sm;
@apply absolute top-2 left-2 text-sm text-white;
}
}
}
&-title {
@apply p-2 text-sm text-white truncate;
@apply mt-2 text-sm line-clamp-1;
@apply text-gray-900 dark:text-white;
}
}
}
.loading-more {
@apply flex items-center justify-center py-4 text-sm text-gray-400;
@apply flex justify-center items-center py-4;
@apply text-gray-500 dark:text-gray-400;
}
.no-more {
@apply text-center py-4 text-sm text-gray-500;
@apply text-center py-4;
@apply text-gray-500 dark:text-gray-400;
}
.mobile {
.recommend-title {
@apply text-xl font-bold px-4;
}
.recommend-list {
@apply px-4 gap-4;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
// 添加歌单分类样式
.play-list-type {
.categories-wrapper {
@apply flex items-center py-2;
white-space: nowrap;
}
&-item {
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply border border-gray-200 dark:border-gray-700;
&:hover {
@apply bg-green-50 dark:bg-green-900;
}
&.active {
@apply bg-green-500 border-green-500 text-white;
}
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>

View File

@@ -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,7 +71,7 @@ const timerIsQr = (key: string) => {
clearInterval(timer);
timerRef.value = null;
}
}, 2000);
}, 3000);
return timer;
};
@@ -79,9 +85,9 @@ onBeforeUnmount(() => {
});
// 是否扫码登陆
const isQr = ref(!isMobile.value);
const chooseQr = () => {
isQr.value = !isQr.value;
loadLogin();
};
// 手机号登录
@@ -116,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>
@@ -129,26 +136,26 @@ const loginPhone = async () => {
<style lang="scss" scoped>
.login-page {
@apply flex flex-col items-center justify-center p-20 pt-20;
@apply bg-light dark:bg-black;
}
.login-title {
@apply text-2xl font-bold mb-6;
@apply text-2xl font-bold mb-6 text-white;
}
.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-color: #383838;
box-shadow: inset 0px 0px 20px 5px #0000005e;
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");
box-shadow: inset 0px 0px 20px 5px rgba(0, 0, 0, 0.37);
.bg {
@apply absolute w-full h-full bg-black opacity-30;
@apply absolute w-full h-full bg-light-100 dark:bg-dark-100 opacity-20;
}
.bottom {
@@ -159,35 +166,42 @@ const loginPhone = async () => {
left: 50%;
padding: 10px;
transform: translateX(-50%);
color: #ffffff99;
@apply absolute bg-black flex justify-center text-lg font-bold cursor-pointer;
box-shadow: 10px 0px 20px #000000a9;
@apply absolute bg-light dark:bg-dark flex justify-center text-lg font-bold cursor-pointer;
@apply text-gray-400 hover:text-gray-800 hover:dark:text-white transition-colors;
box-shadow: 10px 0px 20px rgba(0, 0, 0, 0.66);
}
.content {
@apply absolute w-full h-full p-4 flex flex-col items-center justify-center pb-20 text-center;
.qr-img {
@apply opacity-80 rounded-2xl cursor-pointer;
@apply rounded-2xl cursor-pointer transition-opacity;
}
.phone {
animation-duration: 0.5s;
&-page {
background-color: #ffffffdd;
@apply bg-light dark:bg-gray-800 bg-opacity-90 dark:bg-opacity-90;
width: 250px;
@apply rounded-2xl overflow-hidden;
}
&-input {
height: 40px;
border-bottom: 1px solid #e5e5e5;
@apply w-full text-black px-4 outline-none;
@apply w-full px-4 outline-none;
@apply text-gray-900 dark:text-white bg-transparent;
@apply border-b border-gray-200 dark:border-gray-700;
@apply placeholder-gray-500 dark:placeholder-gray-400;
&:focus {
@apply border-green-500;
}
}
}
.btn-login {
width: 250px;
height: 40px;
@apply mt-10 text-white rounded-xl bg-black opacity-60;
@apply mt-10 text-white rounded-xl;
@apply bg-green-600 hover:bg-green-700 transition-colors;
}
}
}

View File

@@ -2,9 +2,11 @@
<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">
@@ -16,16 +18,29 @@
<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">
<!-- <div class="control-button" @click="handleTop">
<i class="ri-pushpin-line" :class="{ active: lyricSetting.isTop }"></i>
</div>
<div class="control-button" @click="handleLock">
</div> -->
<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>
@@ -61,7 +76,7 @@
</div>
</div>
</template>
<div v-else class="lyric-empty">无歌词</div>
<div v-else class="lyric-empty">无歌词</div>
</div>
</div>
</div>
@@ -69,31 +84,35 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
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 isInitialized = ref(false);
// 字体大小控制
const fontSize = ref(24); // 默认字体大小
const fontSizeStep = 2; // 每次整的步长
const animationFrameId = ref<number | null>(null);
const lastUpdateTime = ref(performance.now());
// 静态数据
const staticData = ref<{
lrcArray: Array<{ text: string; trText: string }>;
lrcTimeArray: number[];
allTime: number;
playMusic: SongResult;
}>({
lrcArray: [],
lrcTimeArray: [],
allTime: 0,
playMusic: {} as SongResult,
});
// 动态数据
@@ -136,14 +155,19 @@ const clearHideTimer = () => {
// 处理鼠标进入窗口
const handleMouseEnter = () => {
if (!lyricSetting.value.isLock) return;
isHovering.value = true;
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);
};
// 监听锁定状态变化
@@ -169,7 +193,7 @@ onUnmounted(() => {
// 计算歌词滚动位置
const wrapperStyle = computed(() => {
if (!isInitialized.value || !containerHeight.value) {
if (!containerHeight.value) {
return {
transform: 'translateY(0)',
transition: 'none',
@@ -180,7 +204,7 @@ const wrapperStyle = computed(() => {
const containerCenter = containerHeight.value / 2;
// 计算当前行到顶部的距离包含padding
const currentLineTop = currentIndex.value * lineHeight.value + containerHeight.value * 0.2; // 加上顶部padding
const currentLineTop = currentIndex.value * lineHeight.value + containerHeight.value * 0.2 + lineHeight.value; // 加上顶部padding
// 计算偏移量,使当前行居中
const targetOffset = containerCenter - currentLineTop;
@@ -197,7 +221,7 @@ const wrapperStyle = computed(() => {
return {
transform: `translateY(${finalOffset}px)`,
transition: isInitialized.value ? 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
};
});
@@ -265,17 +289,13 @@ onMounted(() => {
resizeObserver.disconnect();
});
});
// 动画帧ID
const animationFrameId = ref<number | null>(null);
// 实际播放时间
const actualTime = ref(0);
// 计算当前行的进度
const currentProgress = computed(() => {
const { startCurrentTime, nextTime, isPlay } = dynamicData.value;
if (!startCurrentTime || !nextTime || !isPlay) return 0;
const { startCurrentTime, nextTime } = dynamicData.value;
if (!startCurrentTime || !nextTime) return 0;
const duration = nextTime - startCurrentTime;
const elapsed = actualTime.value - startCurrentTime;
@@ -317,9 +337,8 @@ const updateProgress = () => {
};
// 记录上次更新时间
const lastUpdateTime = ref(performance.now());
// 监听据更新
// 监听据更新
watch(
() => dynamicData.value,
(newData: any) => {
@@ -351,29 +370,41 @@ watch(
},
);
// 修改数据更新处
// 修改数据更新处
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 || typeof parsedData.nowTime !== 'number') {
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,
startCurrentTime: parsedData.startCurrentTime,
nextTime: parsedData.nextTime,
nowTime: parsedData.nowTime || 0,
startCurrentTime: parsedData.startCurrentTime || 0,
nextTime: parsedData.nextTime || 0,
isPlay: parsedData.isPlay,
};
// 更新索引
if (typeof parsedData.nowIndex === 'number' && parsedData.nowIndex !== currentIndex.value) {
if (typeof parsedData.nowIndex === 'number') {
currentIndex.value = parsedData.nowIndex;
}
};
@@ -394,33 +425,7 @@ onMounted(() => {
windowData.electron.ipcRenderer.on('receive-lyric', (data: string) => {
try {
const parsedData = JSON.parse(data);
if (parsedData.type === 'init') {
// 初始化重置状态
currentIndex.value = 0;
isInitialized.value = false;
// 清理可能存在的动画
if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
// 保据格式正确
if (Array.isArray(parsedData.lrcArray)) {
staticData.value = {
lrcArray: parsedData.lrcArray,
lrcTimeArray: parsedData.lrcTimeArray || [],
allTime: parsedData.allTime || 0,
};
} else {
console.error('Invalid lyric array format:', parsedData);
}
nextTick(() => {
isInitialized.value = true;
});
} else if (parsedData.type === 'update') {
handleDataUpdate(parsedData);
}
handleDataUpdate(parsedData);
} catch (error) {
console.error('Error parsing lyric data:', error);
}
@@ -446,6 +451,7 @@ const handleTop = () => {
const handleLock = () => {
lyricSetting.value.isLock = !lyricSetting.value.isLock;
windowData.electron.ipcRenderer.send('set-ignore-mouse', lyricSetting.value.isLock);
};
const handleClose = () => {
@@ -459,6 +465,87 @@ watch(
},
{ deep: true },
);
// 添<><E6B7BB>拖动相关变量
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>
@@ -474,67 +561,79 @@ body {
position: relative;
overflow: hidden;
background: transparent;
user-select: none;
transition: background-color 0.2s ease;
cursor: default;
&:hover {
background: rgba(0, 0, 0, 0.5);
.control-bar {
&-show {
opacity: 1;
visibility: visible;
}
}
}
&:active {
cursor: grabbing;
}
&.dark {
--bg-color: transparent;
--text-color: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.6);
--highlight-color: #1db954;
--control-bg: rgba(0, 0, 0, 0.3);
--control-bg: rgba(124, 124, 124, 0.3);
}
&.light {
--bg-color: transparent;
--text-color: #333333;
--text-secondary: rgba(51, 51, 51, 0.6);
--highlight-color: #1db954;
--control-bg: rgba(255, 255, 255, 0.3);
}
&.lyric_lock {
.control-bar {
background: var(--control-bg);
&-show {
opacity: 1;
}
}
}
}
.control-bar {
position: absolute;
top: 0;
top: 10px;
left: 0;
right: 0;
height: 40px;
background: var(--control-bg);
backdrop-filter: blur(8px);
height: 80px;
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
align-items: start;
padding: 0 20px;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s ease,
visibility 0.2s ease;
-webkit-app-region: drag;
z-index: 100;
&-show {
opacity: 1;
visibility: visible;
.font-size-controls {
-webkit-app-region: no-drag;
color: var(--text-color);
display: flex;
align-items: center;
gap: 16px;
}
.font-size-controls {
margin-right: auto; // 将字体控制放在侧
padding-right: 20px;
.play-controls {
position: absolute;
top: 0px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 16px;
-webkit-app-region: no-drag;
.n-button {
.play-button {
width: 36px;
height: 36px;
i {
font-size: 16px;
font-size: 24px;
}
}
}
@@ -551,23 +650,21 @@ body {
}
.control-button {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
border-radius: 8px;
color: var(--text-color);
transition: all 0.2s ease;
backdrop-filter: blur(4px);
&:hover {
background: var(--control-bg);
}
i {
font-size: 18px;
font-size: 20px;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
&.active {
@@ -578,11 +675,12 @@ body {
.lyric-container {
position: absolute;
top: 40px;
top: 80px;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 100;
}
.lyric-scroll {
@@ -616,8 +714,7 @@ body {
opacity: 1;
}
&.lyric-line-passed,
&.lyric-line-next {
&.lyric-line-passed {
opacity: 0.6;
}
}
@@ -663,4 +760,38 @@ body {
.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 {
background: transparent;
}
#lyric-lock {
position: absolute;
top: 0;
right: 72px;
background: var(--control-bg);
}
}
</style>

View File

@@ -1,7 +1,20 @@
<template>
<div class="mv-list">
<div class="mv-list-title">
<h2>推荐MV</h2>
<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')">
@@ -10,17 +23,10 @@
:key="item.id"
class="mv-item"
:class="setAnimationClass('animate__bounceIn')"
:style="getItemAnimationDelay(index)"
:style="getAnimationDelay(index)"
>
<div class="mv-item-img" @click="handleShowMv(item, index)">
<n-image
class="mv-item-img-img"
:src="getImgUrl(item.cover, '200y112')"
lazy
preview-disabled
width="200"
height="112"
/>
<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>
@@ -45,11 +51,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { 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';
@@ -65,13 +72,29 @@ const initLoading = ref(false);
const loadingMore = ref(false);
const currentIndex = ref(0);
const offset = ref(0);
const limit = ref(30);
const limit = ref(42);
const hasMore = ref(true);
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % limit.value;
return setAnimationDelay(currentPageIndex, 30);
};
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 getAnimationDelay = computed(() => {
return (index: number) => setAnimationDelay(index, 30);
});
onMounted(async () => {
await loadMvList();
@@ -80,6 +103,7 @@ onMounted(async () => {
const handleShowMv = async (item: IMvItem, index: number) => {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showMv.value = true;
currentIndex.value = index;
playMvItem.value = item;
@@ -121,26 +145,26 @@ const playNextMv = async (setLoading: (value: boolean) => void) => {
};
const loadMvList = async () => {
if (!hasMore.value || loadingMore.value) return;
if (offset.value === 0) {
initLoading.value = true;
} else {
loadingMore.value = true;
}
try {
const res = await getTopMv(limit.value, offset.value);
if (!hasMore.value || loadingMore.value) return;
if (offset.value === 0) {
mvList.value = res.data.data;
initLoading.value = true;
} else {
mvList.value.push(...res.data.data);
loadingMore.value = true;
}
hasMore.value = res.data.data.length === limit.value;
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;
} catch (error) {
console.error('加载MV失败:', error);
} finally {
initLoading.value = false;
loadingMore.value = false;
@@ -162,22 +186,53 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
<style scoped lang="scss">
.mv-list {
@apply relative h-full w-full;
@apply h-full flex-1 flex flex-col overflow-hidden;
&-title {
@apply text-xl font-bold;
@apply text-xl font-bold pb-2;
@apply text-gray-900 dark:text-white;
}
// 添加歌单分类样式
.play-list-type {
.title {
@apply text-lg font-bold mb-2;
@apply text-gray-900 dark:text-white;
}
.categories-wrapper {
@apply flex items-center py-2;
white-space: nowrap;
}
&-item {
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply border border-gray-200 dark:border-gray-700;
&:hover {
@apply bg-green-50 dark:bg-green-900;
}
&.active {
@apply bg-green-500 border-green-500 text-white;
}
}
}
&-content {
@apply grid gap-6 pb-28 mt-2 pr-4;
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 {
@apply p-2 rounded-lg;
background-color: #1f1f1f;
@apply bg-light dark:bg-black;
@apply border border-gray-200 dark:border-gray-700;
&-img {
@apply rounded-lg overflow-hidden relative;
aspect-ratio: 16/9;
line-height: 0;
&:hover img {
@@ -185,51 +240,43 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
}
&-img {
@apply w-full rounded-lg overflow-hidden;
@apply w-full h-full object-cover rounded-lg 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: #0000009b;
@apply bg-black bg-opacity-60;
opacity: 0;
i {
font-size: 40px;
transition: all 0.5s ease-in-out;
opacity: 0;
}
&:hover {
@apply opacity-100;
}
&:hover i {
@apply transform scale-150 opacity-80;
@apply text-4xl text-white;
}
.play-count {
position: absolute;
top: 20px;
left: 10px;
font-size: 14px;
@apply absolute top-2 right-2 text-sm;
@apply text-white text-opacity-90;
}
&:hover {
opacity: 1;
}
}
}
&-title {
@apply p-2 text-sm text-white truncate;
@apply mt-2 text-sm line-clamp-1;
@apply text-gray-900 dark:text-white;
}
}
}
.mobile {
.mv-list-content {
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
}
.loading-more {
@apply text-center py-4 col-span-full;
@apply text-gray-500 dark:text-gray-400;
}
.loading-more,
.no-more {
@apply col-span-full text-center py-4 text-gray-400;
@apply text-center py-4 col-span-full;
@apply text-gray-500 dark:text-gray-400;
}
</style>

View File

@@ -165,21 +165,34 @@ const handlePlay = () => {
.search-page {
@apply flex h-full;
}
.hot-search {
@apply mr-4 rounded-xl flex-1 overflow-hidden;
background-color: #0d0d0d;
@apply mr-4 rounded-xl flex-1 overflow-hidden;
@apply bg-light-100 dark:bg-dark-100;
animation-duration: 0.2s;
min-width: 400px;
height: 100%;
&-list {
@apply pb-28;
}
&-item {
@apply px-4 py-3 text-lg hover:bg-gray-700 rounded-xl cursor-pointer;
@apply px-4 py-3 text-lg rounded-xl cursor-pointer;
@apply text-gray-900 dark:text-white;
transition: all 0.3s ease;
&:hover {
@apply bg-light-100 dark:bg-dark-200;
}
&-count {
@apply text-green-500 inline-block ml-3 w-8;
@apply inline-block ml-3 w-8;
@apply text-green-500;
&-3 {
@apply text-red-600 font-bold inline-block ml-3 w-8;
@apply font-bold inline-block ml-3 w-8;
@apply text-red-500;
}
}
}
@@ -187,15 +200,17 @@ const handlePlay = () => {
.search-list {
@apply flex-1 rounded-xl;
background-color: #0d0d0d;
@apply bg-light-100 dark:bg-dark-100;
height: 100%;
&-box {
@apply pb-28;
}
}
.title {
@apply text-gray-200 text-xl font-bold my-2 mx-4;
@apply text-xl font-bold my-2 mx-4;
@apply text-gray-900 dark:text-white;
}
.mobile {

View File

@@ -1,78 +1,124 @@
<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">
<div class="set-item">
<div>
<div class="set-item-title">主题模式</div>
<div class="set-item-content">切换日间/夜间主题</div>
</div>
<n-switch v-model:value="isDarkTheme">
<template #checked>
<i class="ri-moon-line"></i>
</template>
<template #unchecked>
<i class="ri-sun-line"></i>
</template>
</n-switch>
</div>
<n-switch v-model:value="setData.isProxy" />
</div>
<div class="set-item">
<div>
<div class="set-item-title">减轻动画效果</div>
<!-- <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> -->
<div class="set-item">
<div>
<div class="set-item-title">关闭动画效果</div>
<div class="set-item-content">关闭所有页面动画效果</div>
</div>
<n-switch v-model:value="setData.noAnimate" />
</div>
<n-switch v-model:value="setData.noAnimate" />
</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>{{ config.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>{{ 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>
</div>
</n-scrollbar>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { computed } from 'vue';
import { useStore } from 'vuex';
import config from '@/../package.json';
import store from '@/store';
import { isElectron } from '@/hooks/MusicHook';
defineOptions({
name: 'Setting',
const store = useStore();
const setData = computed({
get: () => store.state.setData,
set: (value) => store.commit('setSetData', value),
});
const setData = ref(store.state.setData);
const router = useRouter();
const isDarkTheme = computed({
get: () => store.state.theme === 'dark',
set: () => store.commit('toggleTheme'),
});
const handelCancel = () => {
router.back();
};
const windowData = window as any;
const handleSave = () => {
store.commit('setSetData', setData.value);
if (windowData.electronAPI) {
windowData.electronAPI.restart();
}
const openAuthor = () => {
window.open(setData.value.authorUrl);
};
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
.set-page {
@apply flex flex-col justify-center items-center pt-8;
@apply p-4 bg-light dark:bg-dark;
}
.set-item {
@apply w-3/5 flex justify-between items-center mb-4;
.set-item-title {
@apply text-gray-200 text-base;
@apply flex items-center justify-between p-4 rounded-lg mb-4 transition-all;
@apply bg-light dark:bg-dark text-gray-900 dark:text-white;
@apply border border-gray-200 dark:border-gray-700;
&-title {
@apply text-base font-medium mb-1;
}
.set-item-content {
@apply text-gray-400 text-sm;
&-content {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
&.cursor-pointer:hover {
@apply text-green-500 bg-green-50 dark:bg-green-900;
}
}
</style>

View File

@@ -1,3 +1,80 @@
<template>
<div class="user-page">
<div
v-if="userDetail"
class="left"
:class="setAnimationClass('animate__fadeInLeft')"
:style="{ backgroundImage: `url(${getImgUrl(user.backgroundUrl)})` }"
>
<div class="page">
<div class="user-name">{{ user.nickname }}</div>
<div class="user-info">
<n-avatar round :size="50" :src="getImgUrl(user.avatarUrl, '50y50')" />
<div class="user-info-list">
<div class="user-info-item">
<div class="label">{{ userDetail.profile.followeds }}</div>
<div>粉丝</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.profile.follows }}</div>
<div>关注</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.level }}</div>
<div>等级</div>
</div>
</div>
</div>
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">创建的歌单</div>
<n-scrollbar>
<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>
<div class="play-list-item-count">{{ item.trackCount }}播放{{ item.playCount }}</div>
</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</div>
</div>
<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.id"
class="record-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 25)"
>
<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 || []"
:list-info="list"
:loading="listLoading"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -77,108 +154,41 @@ const handlePlay = () => {
};
</script>
<template>
<div class="user-page">
<div
v-if="userDetail"
class="left"
:class="setAnimationClass('animate__fadeInLeft')"
:style="{ backgroundImage: `url(${getImgUrl(user.backgroundUrl)})` }"
>
<div class="page">
<div class="user-name">{{ user.nickname }}</div>
<div class="user-info">
<n-avatar round :size="50" :src="getImgUrl(user.avatarUrl, '50y50')" />
<div class="user-info-list">
<div class="user-info-item">
<div class="label">{{ userDetail.profile.followeds }}</div>
<div>粉丝</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.profile.follows }}</div>
<div>关注</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.level }}</div>
<div>等级</div>
</div>
</div>
</div>
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">创建的歌单</div>
<n-scrollbar>
<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>
<div class="play-list-item-count">{{ item.trackCount }}播放{{ item.playCount }}</div>
</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</div>
</div>
<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.id"
class="record-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 25)"
>
<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 || []"
:list-info="list"
:loading="listLoading"
/>
</div>
</template>
<style lang="scss" scoped>
.user-page {
@apply flex h-full;
.left {
max-width: 600px;
background-color: #0d0d0d;
background-size: 100%;
@apply flex-1 rounded-2xl overflow-hidden relative bg-no-repeat h-full;
@apply flex-1 rounded-2xl overflow-hidden relative bg-no-repeat h-full;
@apply bg-gray-900 dark:bg-gray-800;
.page {
@apply p-4 w-full z-10 flex flex-col h-full;
background-color: #0d0d0d66;
@apply p-4 w-full z-10 flex flex-col h-full;
@apply bg-black bg-opacity-40;
}
.title {
@apply text-lg font-bold;
@apply text-gray-900 dark:text-white;
}
.user-name {
@apply text-xl text-white font-bold opacity-70 mb-4;
@apply text-xl font-bold mb-4;
@apply text-white text-opacity-70;
}
.uesr-signature {
@apply text-white opacity-70 mt-4;
@apply mt-4;
@apply text-white text-opacity-70;
}
.user-info {
@apply flex items-center;
&-list {
@apply flex justify-around w-2/5 text-center opacity-70;
@apply flex justify-around w-2/5 text-center;
@apply text-white text-opacity-70;
.label {
@apply text-xl text-white font-bold;
@apply text-xl font-bold text-white;
}
}
}
@@ -186,10 +196,12 @@ const handlePlay = () => {
.right {
@apply flex-1 ml-4 overflow-hidden h-full;
.record-list {
background-color: #0d0d0d;
@apply rounded-2xl;
@apply bg-light dark:bg-black;
height: calc(100% - 3.75rem);
.record-item {
@apply flex items-center px-4;
}
@@ -199,33 +211,48 @@ const handlePlay = () => {
}
.play-count {
@apply text-white opacity-70 ml-4;
@apply ml-4;
@apply text-gray-600 dark:text-gray-400;
}
}
.title {
@apply text-xl text-white font-bold opacity-70 m-4;
@apply text-xl font-bold m-4;
@apply text-gray-900 dark:text-white;
}
}
}
.play-list {
@apply mt-4 py-4 px-2 rounded-xl flex-1 overflow-hidden;
background-color: #000000;
@apply bg-light dark:bg-black;
&-title {
@apply text-lg text-white opacity-70;
@apply text-lg;
@apply text-gray-900 dark:text-white;
}
&-item {
@apply flex items-center hover:bg-gray-800 transition-all duration-200 px-2 py-1 rounded-xl cursor-pointer;
@apply flex items-center px-2 py-1 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
&-img {
width: 60px;
height: 60px;
@apply rounded-xl;
}
&-info {
@apply ml-2;
}
&-name {
@apply text-white;
@apply text-gray-900 dark:text-white text-base;
}
&-count {
@apply text-gray-500 dark:text-gray-400;
}
}
}

View File

@@ -1,8 +1,34 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
extend: {
colors: {
primary: {
DEFAULT: '#000',
light: '#fff',
dark: '#000',
},
secondary: {
DEFAULT: '#6c757d',
light: '#8c959e',
dark: '#495057',
},
dark: {
DEFAULT: '#000',
100: '#161616',
200: '#2d2d2d',
300: '#3d3d3d',
},
light: {
DEFAULT: '#fff',
100: '#f8f9fa',
200: '#e9ecef',
300: '#dee2e6',
},
},
},
},
plugins: [],
};

View File

@@ -36,10 +36,10 @@ export default defineConfig({
port: 4488,
proxy: {
// with options
[process.env.VITE_API_PROXY as string]: {
[process.env.VITE_API_LOCAL as string]: {
target: process.env.VITE_API,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY}`), ''),
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_LOCAL}`), ''),
},
[process.env.VITE_API_MUSIC_PROXY as string]: {
target: process.env.VITE_API_MUSIC,