mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-08 10:00:50 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea1e5751f | ||
|
|
f2ebb04fab | ||
|
|
42048764d5 | ||
|
|
e326253fd8 | ||
|
|
edf5c77ea0 | ||
|
|
8870390770 | ||
|
|
c9514e6e19 | ||
|
|
08fa160de4 | ||
|
|
5d4c4922fd | ||
|
|
c5e7c87658 | ||
|
|
f6923b4c47 | ||
|
|
4cf7598a7d | ||
|
|
81b09bef0d | ||
|
|
b21df3de25 | ||
|
|
c49d814182 | ||
|
|
1cb3c72ab7 | ||
|
|
f03372de6a | ||
|
|
d925f40303 |
@@ -1,3 +1,12 @@
|
||||
VITE_API = /api
|
||||
VITE_API_MUSIC = /music
|
||||
VITE_API_PROXY = http://110.42.251.190:9856
|
||||
# 你的接口地址 (必填)
|
||||
VITE_API = ***
|
||||
# 音乐破解接口地址
|
||||
VITE_API_MUSIC = ***
|
||||
# 代理地址
|
||||
VITE_API_PROXY = ***
|
||||
|
||||
|
||||
# 本地运行代理地址
|
||||
VITE_API_PROXY = /api
|
||||
VITE_API_MUSIC_PROXY = /music
|
||||
VITE_API_PROXY_MUSIC = /music_proxy
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
VITE_API = http://110.42.251.190:9898
|
||||
VITE_API_MUSIC = http://110.42.251.190:4100
|
||||
VITE_API_PROXY = http://110.42.251.190:9856
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ dist.zip
|
||||
.vscode
|
||||
|
||||
bun.lockb
|
||||
|
||||
.env.*.local
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
69
README.md
69
README.md
@@ -1,4 +1,4 @@
|
||||
# 一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||
# Alger Music Player
|
||||
主要功能如下
|
||||
|
||||
- 音乐推荐
|
||||
@@ -8,6 +8,42 @@
|
||||
- 桌面歌词
|
||||
- 歌单 mv 搜索 专辑等功能
|
||||
|
||||
## 项目简介
|
||||
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
|
||||
|
||||
## 预览地址
|
||||
[http://mc.alger.fun/](http://mc.alger.fun/)
|
||||
|
||||
## Stargazers over time
|
||||
[](https://starchart.cc/algerkong/AlgerMusicPlayer)
|
||||
|
||||
## 软件截图
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
# .env.production
|
||||
# 你的接口地址 (必填)
|
||||
VITE_API = ***
|
||||
# 音乐破解接口地址
|
||||
VITE_API_MUSIC = ***
|
||||
# 代理地址
|
||||
VITE_API_PROXY = ***
|
||||
```
|
||||
|
||||
## 欢迎提Issues
|
||||
|
||||
|
||||
7
app.js
7
app.js
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const Store = require('electron-store');
|
||||
const setJson = require('./electron/set.json');
|
||||
const { loadLyricWindow } = require('./electron/lyric');
|
||||
const config = require('./electron/config');
|
||||
|
||||
let mainWin = null;
|
||||
function createWindow() {
|
||||
@@ -11,8 +12,8 @@ function createWindow() {
|
||||
height: 780,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
// contextIsolation: false,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, '/electron/preload.js'),
|
||||
},
|
||||
});
|
||||
@@ -20,7 +21,7 @@ function createWindow() {
|
||||
win.setMinimumSize(1200, 780);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
win.loadURL('http://localhost:4488/');
|
||||
win.loadURL(`http://localhost:${config.development.mainPort}/`);
|
||||
} else {
|
||||
win.loadURL(`file://${__dirname}/dist/index.html`);
|
||||
}
|
||||
|
||||
3
components.d.ts
vendored
3
components.d.ts
vendored
@@ -7,11 +7,13 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
InstallAppModal: typeof import('./src/components/common/InstallAppModal.vue')['default']
|
||||
MPop: typeof import('./src/components/common/MPop.vue')['default']
|
||||
MusicList: typeof import('./src/components/MusicList.vue')['default']
|
||||
MvPlayer: typeof import('./src/components/MvPlayer.vue')['default']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
@@ -21,6 +23,7 @@ declare module 'vue' {
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSlider: typeof import('naive-ui')['NSlider']
|
||||
|
||||
BIN
docs/img/image-6.png
Normal file
BIN
docs/img/image-6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
docs/img/image-7.png
Normal file
BIN
docs/img/image-7.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
BIN
docs/img/image-8.png
Normal file
BIN
docs/img/image-8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
11
electron/config.js
Normal file
11
electron/config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
// 开发环境配置
|
||||
development: {
|
||||
mainPort: 4488,
|
||||
lyricPort: 4488,
|
||||
},
|
||||
// 生产环境配置
|
||||
production: {
|
||||
distPath: '../dist',
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
let lyricWindow = null;
|
||||
|
||||
@@ -10,13 +11,16 @@ const createWin = () => {
|
||||
frame: false,
|
||||
show: false,
|
||||
transparent: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: `${__dirname}/preload.js`,
|
||||
contextIsolation: false,
|
||||
webSecurity: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const loadLyricWindow = (ipcMain) => {
|
||||
ipcMain.on('open-lyric', () => {
|
||||
if (lyricWindow) {
|
||||
@@ -28,9 +32,9 @@ const loadLyricWindow = (ipcMain) => {
|
||||
createWin();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
lyricWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
lyricWindow.loadURL('http://localhost:4678/#/lyric');
|
||||
lyricWindow.loadURL(`http://localhost:${config.development.lyricPort}/#/lyric`);
|
||||
} else {
|
||||
const distPath = path.resolve(__dirname, '../dist');
|
||||
const distPath = path.resolve(__dirname, config.production.distPath);
|
||||
lyricWindow.loadURL(`file://${distPath}/index.html#/lyric`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { contextBridge, ipcRenderer, app } = require('electron');
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 主进程通信
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
minimize: () => ipcRenderer.send('minimize-window'),
|
||||
maximize: () => ipcRenderer.send('maximize-window'),
|
||||
@@ -11,18 +12,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
sendLyric: (data) => ipcRenderer.send('send-lyric', data),
|
||||
});
|
||||
|
||||
const electronHandler = {
|
||||
// 存储相关
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
ipcRenderer: {
|
||||
setStoreValue: (key, value) => {
|
||||
ipcRenderer.send('setStore', key, value);
|
||||
setStoreValue: (key, value) => ipcRenderer.send('setStore', key, value),
|
||||
getStoreValue: (key) => ipcRenderer.sendSync('getStore', key),
|
||||
on: (channel, func) => {
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
},
|
||||
|
||||
getStoreValue(key) {
|
||||
const resp = ipcRenderer.sendSync('getStore', key);
|
||||
return resp;
|
||||
once: (channel, func) => {
|
||||
ipcRenderer.once(channel, (event, ...args) => func(...args));
|
||||
},
|
||||
send: (channel, data) => {
|
||||
ipcRenderer.send(channel, data);
|
||||
},
|
||||
},
|
||||
app,
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', electronHandler);
|
||||
});
|
||||
|
||||
69
electron/update.js
Normal file
69
electron/update.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
class Updater {
|
||||
constructor(mainWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.updateUrl = 'http://your-server.com/update'; // 更新服务器地址
|
||||
this.version = app.getVersion();
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
const response = await axios.get(`${this.updateUrl}/check`, {
|
||||
params: {
|
||||
version: this.version,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.hasUpdate) {
|
||||
await this.downloadUpdate(response.data.downloadUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
async downloadUpdate(downloadUrl) {
|
||||
try {
|
||||
const response = await axios({
|
||||
url: downloadUrl,
|
||||
method: 'GET',
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
const tempPath = path.join(app.getPath('temp'), 'update.zip');
|
||||
fs.writeFileSync(tempPath, response.data);
|
||||
|
||||
await this.extractUpdate(tempPath);
|
||||
} catch (error) {
|
||||
console.error('下载更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 解压更新
|
||||
async extractUpdate(zipPath) {
|
||||
try {
|
||||
const zip = new AdmZip(zipPath);
|
||||
const targetPath = path.join(__dirname, '../dist'); // 前端文件目录
|
||||
|
||||
// 解压文件
|
||||
zip.extractAllTo(targetPath, true);
|
||||
|
||||
// 删除临时文件
|
||||
fs.unlinkSync(zipPath);
|
||||
|
||||
// 刷新页面
|
||||
this.mainWindow.webContents.reload();
|
||||
} catch (error) {
|
||||
console.error('解压更新失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Updater;
|
||||
13
package.json
13
package.json
@@ -13,7 +13,8 @@
|
||||
"b:win:x64": "cross-env NODE_ENV=production electron-builder --config ./build/win64.json",
|
||||
"b:win:x86": "cross-env NODE_ENV=production electron-builder --config ./build/win32.json",
|
||||
"b:win:arm": "cross-env NODE_ENV=production electron-builder --config ./build/winarm64.json",
|
||||
"b:mac": "cross-env NODE_ENV=production electron-builder --config ./build/mac.json"
|
||||
"b:mac": "cross-env NODE_ENV=production npm run build && electron-builder --config ./build/mac.json",
|
||||
"b:win": "cross-env NODE_ENV=production npm run build && npm run b:win:x64 && npm run b:win:x86 && npm run b:win:arm"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.1.0"
|
||||
@@ -30,7 +31,8 @@
|
||||
"@vueuse/electron": "^11.0.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.7.7",
|
||||
"electron": "^32.0.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^32.2.7",
|
||||
"electron-builder": "^25.0.5",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
@@ -42,11 +44,11 @@
|
||||
"eslint-plugin-vue-scoped-css": "^2.7.2",
|
||||
"lodash": "^4.17.21",
|
||||
"naive-ui": "^2.39.0",
|
||||
"postcss": "^8.4.44",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"remixicon": "^4.2.0",
|
||||
"sass": "^1.78.0",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.5.4",
|
||||
"unplugin-auto-import": "^0.18.2",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
@@ -57,7 +59,6 @@
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.3",
|
||||
"vue-tsc": "^2.1.4",
|
||||
"vuex": "^4.1.0",
|
||||
"cross-env": "^7.0.3"
|
||||
"vuex": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
10
src/App.vue
10
src/App.vue
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app" :class="isMobile ? 'mobile' : ''">
|
||||
<div class="app-container" :class="{ mobile: isMobile }">
|
||||
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<n-dialog-provider>
|
||||
@@ -9,7 +9,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { darkTheme } from 'naive-ui';
|
||||
|
||||
import store from '@/store';
|
||||
@@ -29,10 +29,8 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.app {
|
||||
.app-container {
|
||||
@apply h-full w-full;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,77 +10,69 @@
|
||||
>
|
||||
<div class="music-page">
|
||||
<div class="music-close">
|
||||
<i class="icon ri-layout-column-line" @click="doubleDisply = !doubleDisply"></i>
|
||||
<i class="icon iconfont icon-icon_error" @click="close"></i>
|
||||
</div>
|
||||
<div class="music-title text-el">{{ name }}</div>
|
||||
<!-- 歌单歌曲列表 -->
|
||||
<div class="music-list">
|
||||
<n-scrollbar @scroll="handleScroll">
|
||||
<div
|
||||
v-loading="loading || !songList.length"
|
||||
class="music-list-content"
|
||||
:class="{ 'double-list': doubleDisply }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in displayedSongs"
|
||||
:key="item.id"
|
||||
class="double-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="setAnimationDelay(index, 5)"
|
||||
>
|
||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||
</div>
|
||||
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- <n-virtual-list :item-size="42" :items="displayedSongs" item-resizable @scroll="handleScroll">
|
||||
<template #default="{ item, index }">
|
||||
<div :key="item.id" class="double-item">
|
||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||
</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>
|
||||
<play-bottom /> -->
|
||||
<div v-else-if="loading" class="loading-more">加载中...</div>
|
||||
<play-bottom />
|
||||
</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, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import { isMobile } from '@/utils';
|
||||
|
||||
import PlayBottom from './common/PlayBottom.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const {
|
||||
songList,
|
||||
loading = false,
|
||||
listInfo,
|
||||
} = defineProps<{
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
name: string;
|
||||
songList: any[];
|
||||
loading?: boolean;
|
||||
listInfo?: any;
|
||||
listInfo?: {
|
||||
trackIds: { id: number }[];
|
||||
[key: string]: any;
|
||||
};
|
||||
}>();
|
||||
const emit = defineEmits(['update:show', 'update:loading']);
|
||||
|
||||
const page = ref(0);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const isLoadingMore = ref(false);
|
||||
const displayedSongs = ref<any[]>([]);
|
||||
|
||||
// 双排显示开关
|
||||
const doubleDisply = ref(false);
|
||||
// 计算总数
|
||||
const total = computed(() => {
|
||||
if (props.listInfo?.trackIds) {
|
||||
return props.listInfo.trackIds.length;
|
||||
}
|
||||
return props.songList.length;
|
||||
});
|
||||
|
||||
const formatDetail = computed(() => (detail: any) => {
|
||||
const song = {
|
||||
@@ -95,7 +87,7 @@ const formatDetail = computed(() => (detail: any) => {
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
const tracks = songList || [];
|
||||
const tracks = props.songList || [];
|
||||
store.commit(
|
||||
'setPlayList',
|
||||
tracks.map((item) => ({
|
||||
@@ -112,40 +104,70 @@ const close = () => {
|
||||
emit('update:show', false);
|
||||
};
|
||||
|
||||
// 优化加载更多歌曲的函数
|
||||
const loadMoreSongs = async () => {
|
||||
if (displayedSongs.value.length >= total.value) return;
|
||||
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return;
|
||||
|
||||
isLoadingMore.value = true;
|
||||
try {
|
||||
const trackIds = listInfo.trackIds
|
||||
.slice(page.value * pageSize, (page.value + 1) * pageSize)
|
||||
.map((item: any) => item.id);
|
||||
const reslist = await getMusicDetail(trackIds);
|
||||
// displayedSongs.value = displayedSongs.value.concat(reslist.data.songs);
|
||||
displayedSongs.value = JSON.parse(JSON.stringify([...displayedSongs.value, ...reslist.data.songs]));
|
||||
page.value++;
|
||||
if (props.listInfo?.trackIds) {
|
||||
// 如果有 trackIds,需要分批请求歌曲详情
|
||||
const start = page.value * pageSize;
|
||||
const end = Math.min((page.value + 1) * pageSize, total.value);
|
||||
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
|
||||
|
||||
if (trackIds.length > 0) {
|
||||
const { data } = await getMusicDetail(trackIds);
|
||||
displayedSongs.value = [...displayedSongs.value, ...data.songs];
|
||||
page.value++;
|
||||
}
|
||||
} else {
|
||||
// 如果没有 trackIds,直接使用 songList 分页
|
||||
const start = page.value * pageSize;
|
||||
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
|
||||
const newSongs = props.songList.slice(start, end);
|
||||
displayedSongs.value = [...displayedSongs.value, ...newSongs];
|
||||
page.value++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
console.error('加载歌曲失败:', error);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 50 && !isLoadingMore.value) {
|
||||
// 添加虚拟列表的引用
|
||||
const virtualListRef = ref<VirtualListInst | null>(null);
|
||||
|
||||
// 修改滚动处理函数
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) {
|
||||
loadMoreSongs();
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 songList 变化,重置分页状态
|
||||
watch(
|
||||
() => songList,
|
||||
() => props.songList,
|
||||
(newSongs) => {
|
||||
displayedSongs.value = JSON.parse(JSON.stringify(newSongs));
|
||||
total.value = listInfo ? listInfo.trackIds.length : displayedSongs.value.length;
|
||||
page.value = 0;
|
||||
displayedSongs.value = newSongs.slice(0, pageSize);
|
||||
if (newSongs.length > pageSize) {
|
||||
page.value = 1;
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 添加计算属性来处理列表高度
|
||||
const listHeight = computed(() => {
|
||||
const baseHeight = '100%'; // 减去标题高度
|
||||
return store.state.isPlay ? `calc(100% - 90px)` : baseHeight; // 112px 是 PlayBottom 的高度
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -168,9 +190,13 @@ watch(
|
||||
|
||||
&-list {
|
||||
height: calc(100% - 60px);
|
||||
position: relative; // 添加相对定位
|
||||
|
||||
&-content {
|
||||
min-height: 400px;
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,14 +212,20 @@ watch(
|
||||
}
|
||||
|
||||
.double-list {
|
||||
@apply flex flex-wrap gap-5;
|
||||
|
||||
.double-item {
|
||||
width: calc(50% - 10px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
background-color: #191919;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 PlayBottom 不会影响滚动区域
|
||||
:deep(.bottom) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
|
||||
<div class="controls-main">
|
||||
<div class="left-controls">
|
||||
<n-tooltip placement="top">
|
||||
<n-tooltip v-if="!props.noList" placement="top">
|
||||
<template #trigger>
|
||||
<n-button quaternary circle :disabled="isPrevDisabled" @click="handlePrev">
|
||||
<n-button quaternary circle @click="handlePrev">
|
||||
<template #icon>
|
||||
<n-icon size="24">
|
||||
<n-spin v-if="prevLoading" size="small" />
|
||||
@@ -72,7 +72,7 @@
|
||||
{{ isPlaying ? '暂停' : '播放' }}
|
||||
</n-tooltip>
|
||||
|
||||
<n-tooltip placement="top">
|
||||
<n-tooltip v-if="!props.noList" placement="top">
|
||||
<template #trigger>
|
||||
<n-button quaternary circle @click="handleNext">
|
||||
<template #icon>
|
||||
@@ -106,7 +106,7 @@
|
||||
<n-slider v-model:value="volume" :min="0" :max="100" :tooltip="false" class="volume-slider" />
|
||||
</div>
|
||||
|
||||
<n-tooltip placement="top">
|
||||
<n-tooltip v-if="!props.noList" placement="top">
|
||||
<template #trigger>
|
||||
<n-button quaternary circle class="play-mode-btn" @click="togglePlayMode">
|
||||
<template #icon>
|
||||
@@ -171,7 +171,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NButton, NIcon, NSlider, NTooltip, useMessage } from 'naive-ui';
|
||||
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
@@ -184,10 +184,18 @@ const PLAY_MODE = {
|
||||
Auto: 'auto' as PlayMode,
|
||||
} as const;
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
currentMv?: IMvItem;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
currentMv?: IMvItem;
|
||||
noList?: boolean;
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
currentMv: undefined,
|
||||
noList: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
@@ -345,7 +353,9 @@ const handleEnded = () => {
|
||||
}
|
||||
} else {
|
||||
// 自动播放模式,触发下一个
|
||||
emit('next');
|
||||
emit('next', (value: boolean) => {
|
||||
nextLoading.value = value;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -369,9 +379,9 @@ const checkFullscreenAPI = () => {
|
||||
return {
|
||||
requestFullscreen:
|
||||
videoContainerRef.value?.requestFullscreen ||
|
||||
videoContainerRef.value?.webkitRequestFullscreen ||
|
||||
videoContainerRef.value?.mozRequestFullScreen ||
|
||||
videoContainerRef.value?.msRequestFullscreen,
|
||||
(videoContainerRef.value as any)?.webkitRequestFullscreen ||
|
||||
(videoContainerRef.value as any)?.mozRequestFullScreen ||
|
||||
(videoContainerRef.value as any)?.msRequestFullscreen,
|
||||
exitFullscreen: doc.exitFullscreen || doc.webkitExitFullscreen || doc.mozCancelFullScreen || doc.msExitFullscreen,
|
||||
fullscreenElement:
|
||||
doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement,
|
||||
@@ -441,9 +451,6 @@ onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
});
|
||||
|
||||
// 在 setup 中初始化 message
|
||||
const message = useMessage();
|
||||
|
||||
// 添加提示状态
|
||||
const showModeHint = ref(false);
|
||||
|
||||
|
||||
@@ -5,10 +5,21 @@
|
||||
<div>
|
||||
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
||||
<span
|
||||
v-show="isShowAllPlaylistCategory || index <= 19"
|
||||
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
|
||||
class="play-list-type-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index <= 19 ? index : index - 19)"
|
||||
:class="
|
||||
setAnimationClass(
|
||||
index <= 19
|
||||
? 'animate__bounceIn'
|
||||
: !isShowAllPlaylistCategory
|
||||
? 'animate__backOutLeft'
|
||||
: 'animate__bounceIn',
|
||||
) +
|
||||
' ' +
|
||||
'type-item-' +
|
||||
index
|
||||
"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="handleClickPlaylistType(item.name)"
|
||||
>{{ item.name }}</span
|
||||
>
|
||||
@@ -17,7 +28,7 @@
|
||||
class="play-list-type-showall"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30)"
|
||||
@click="isShowAllPlaylistCategory = !isShowAllPlaylistCategory"
|
||||
@click="handleToggleShowAllPlaylistCategory"
|
||||
>
|
||||
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
|
||||
</div>
|
||||
@@ -26,7 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
@@ -36,6 +47,59 @@ import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
// 是否显示全部歌单分类
|
||||
const isShowAllPlaylistCategory = ref<boolean>(false);
|
||||
const DELAY_TIME = 40;
|
||||
const getAnimationDelay = computed(() => {
|
||||
return (index: number) => {
|
||||
if (index <= 19) {
|
||||
return setAnimationDelay(index, DELAY_TIME);
|
||||
}
|
||||
if (!isShowAllPlaylistCategory.value) {
|
||||
const nowIndex = (playlistCategory.value?.sub.length || 0) - index;
|
||||
return setAnimationDelay(nowIndex, DELAY_TIME);
|
||||
}
|
||||
return setAnimationDelay(index - 19, DELAY_TIME);
|
||||
};
|
||||
});
|
||||
|
||||
watch(isShowAllPlaylistCategory, (newVal) => {
|
||||
if (!newVal) {
|
||||
const elements = playlistCategory.value?.sub.map((item, index) =>
|
||||
document.querySelector(`.type-item-${index}`),
|
||||
) as HTMLElement[];
|
||||
elements
|
||||
.slice(20)
|
||||
.reverse()
|
||||
.forEach((element, index) => {
|
||||
if (element) {
|
||||
setTimeout(
|
||||
() => {
|
||||
(element as HTMLElement).style.position = 'absolute';
|
||||
},
|
||||
index * DELAY_TIME + 400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
isHiding.value = false;
|
||||
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||
if (element) {
|
||||
console.log('element', element);
|
||||
(element as HTMLElement).style.position = 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
(playlistCategory.value?.sub.length || 0 - 19) * DELAY_TIME,
|
||||
);
|
||||
} else {
|
||||
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||
if (element) {
|
||||
(element as HTMLElement).style.position = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 加载歌单分类
|
||||
const loadPlaylistCategory = async () => {
|
||||
@@ -52,6 +116,14 @@ const handleClickPlaylistType = (type: string) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isHiding = ref<boolean>(false);
|
||||
const handleToggleShowAllPlaylistCategory = () => {
|
||||
isShowAllPlaylistCategory.value = !isShowAllPlaylistCategory.value;
|
||||
if (!isShowAllPlaylistCategory.value) {
|
||||
isHiding.value = true;
|
||||
}
|
||||
};
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadPlaylistCategory();
|
||||
|
||||
114
src/components/common/InstallAppModal.vue
Normal file
114
src/components/common/InstallAppModal.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="dialog" :show-icon="false" :mask-closable="true" class="install-app-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="app-icon">
|
||||
<img src="@/assets/logo.png" alt="App Icon" />
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<h2 class="app-name">Alger Music</h2>
|
||||
<p class="app-desc">在桌面安装应用,获得更好的体验</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
|
||||
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const showModal = ref(false);
|
||||
const isElectron = ref((window as any).electron !== undefined);
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
localStorage.setItem('installPromptDismissed', 'true');
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
// 新页面打开
|
||||
// 识别当前环境是 mac 还是 windows
|
||||
const os = navigator.platform;
|
||||
const isMac = os.includes('Mac');
|
||||
const isWindows = os.includes('Win');
|
||||
const urls = {
|
||||
mac: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.dmg',
|
||||
windows: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.exe',
|
||||
};
|
||||
// 根据操作系统选择下载链接
|
||||
let downloadUrl = '';
|
||||
if (isMac) {
|
||||
downloadUrl = urls.mac;
|
||||
} else if (isWindows) {
|
||||
downloadUrl = urls.windows;
|
||||
}
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 如果是 electron 环境,不显示安装提示
|
||||
if (isElectron.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经点击过"暂不安装"
|
||||
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
|
||||
if (isDismissed) {
|
||||
return;
|
||||
}
|
||||
showModal.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.install-app-modal {
|
||||
:deep(.n-modal) {
|
||||
@apply max-w-sm;
|
||||
}
|
||||
.modal-content {
|
||||
@apply p-4;
|
||||
.modal-header {
|
||||
@apply flex items-center mb-6;
|
||||
.app-icon {
|
||||
@apply w-16 h-16 mr-4 rounded-2xl overflow-hidden;
|
||||
img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
.app-info {
|
||||
@apply flex-1;
|
||||
.app-name {
|
||||
@apply text-xl font-bold mb-1;
|
||||
}
|
||||
.app-desc {
|
||||
@apply text-sm text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal-actions {
|
||||
@apply flex gap-3;
|
||||
.n-button {
|
||||
@apply flex-1;
|
||||
}
|
||||
.cancel-btn {
|
||||
@apply bg-gray-800 text-gray-300 border-none;
|
||||
&:hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
.install-btn {
|
||||
@apply bg-green-600 border-none;
|
||||
&:hover {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,14 +18,16 @@
|
||||
:song-list="songList"
|
||||
:list-info="listInfo"
|
||||
/>
|
||||
|
||||
<PlayVideo v-if="item.type === 'mv'" v-model:show="showPop" :title="item.name" :url="url" />
|
||||
<mv-player v-if="item.type === 'mv'" v-model:show="showPop" :current-mv="getCurrentMv()" no-list />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getAlbum, getListDetail } from '@/api/list';
|
||||
import { getMvUrl } from '@/api/mv';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -38,13 +40,20 @@ const props = defineProps<{
|
||||
};
|
||||
}>();
|
||||
|
||||
const url = ref('');
|
||||
|
||||
const songList = ref<any[]>([]);
|
||||
|
||||
const showPop = ref(false);
|
||||
const listInfo = ref<any>(null);
|
||||
|
||||
const getCurrentMv = () => {
|
||||
return {
|
||||
id: props.item.id,
|
||||
name: props.item.name,
|
||||
} as unknown as IMvItem;
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const handleClick = async () => {
|
||||
listInfo.value = null;
|
||||
if (props.item.type === '专辑') {
|
||||
@@ -64,8 +73,8 @@ const handleClick = async () => {
|
||||
}
|
||||
|
||||
if (props.item.type === 'mv') {
|
||||
const res = await getMvUrl(props.item.id);
|
||||
url.value = res.data.data.url;
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
showPop.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="song-item" :class="{ 'song-mini': mini }">
|
||||
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
|
||||
<n-image
|
||||
v-if="item.picUrl"
|
||||
ref="songImg"
|
||||
@@ -12,18 +12,29 @@
|
||||
@load="imageLoad"
|
||||
/>
|
||||
<div class="song-item-content">
|
||||
<div class="song-item-content-title">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||
</div>
|
||||
<div class="song-item-content-name">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
<div v-if="list" class="song-item-content-wrapper">
|
||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||
<div class="song-item-content-divider">-</div>
|
||||
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
||||
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
|
||||
>{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="song-item-content-title">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
|
||||
</div>
|
||||
<div class="song-item-content-name">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
|
||||
>{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="song-item-operating">
|
||||
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
|
||||
<div class="song-item-operating-like">
|
||||
<i class="iconfont icon-likefill"></i>
|
||||
</div>
|
||||
@@ -51,9 +62,11 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
item: SongResult;
|
||||
mini?: boolean;
|
||||
list?: boolean;
|
||||
}>(),
|
||||
{
|
||||
mini: false,
|
||||
list: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -175,4 +188,41 @@ const playMusicEvent = async (item: SongResult) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.song-list {
|
||||
@apply p-2 rounded-lg hover:bg-gray-800/50 border border-gray-800/50 mb-2;
|
||||
.song-item-img {
|
||||
@apply w-10 h-10 rounded-lg mr-3;
|
||||
}
|
||||
.song-item-content {
|
||||
@apply flex items-center flex-1;
|
||||
&-wrapper {
|
||||
@apply flex items-center flex-1 text-sm;
|
||||
}
|
||||
&-title {
|
||||
@apply text-white flex-shrink-0 max-w-[45%];
|
||||
}
|
||||
&-divider {
|
||||
@apply mx-2 text-gray-400;
|
||||
}
|
||||
&-name {
|
||||
@apply text-gray-400 flex-1 min-w-0;
|
||||
}
|
||||
}
|
||||
.song-item-operating {
|
||||
@apply flex items-center gap-2;
|
||||
&-like {
|
||||
@apply cursor-pointer hover:scale-110 transition-transform;
|
||||
.iconfont {
|
||||
@apply text-base text-gray-400 hover:text-red-500;
|
||||
}
|
||||
}
|
||||
&-play {
|
||||
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { getMusicLrc } from '@/api/music';
|
||||
import store from '@/store';
|
||||
import { ILyric } from '@/type/lyric';
|
||||
import type { ILyricText, SongResult } from '@/type/music';
|
||||
|
||||
const windowData = window as any;
|
||||
@@ -162,37 +160,110 @@ export const getLrcTimeRange = (index: number) => ({
|
||||
nextTime: lrcTimeArray.value[index + 1],
|
||||
});
|
||||
|
||||
// 监听歌词数组变化,当切换歌曲时重新初始化歌词窗口
|
||||
watch(
|
||||
() => lrcArray.value,
|
||||
(newLrcArray) => {
|
||||
if (newLrcArray.length > 0 && isElectron.value) {
|
||||
// 重新初始化歌词数据
|
||||
initLyricWindow();
|
||||
// 发送当前状态
|
||||
sendLyricToWin();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听播放状态变化
|
||||
watch(isPlaying, (newIsPlaying) => {
|
||||
if (isElectron.value) {
|
||||
sendLyricToWin(newIsPlaying);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听时间变化
|
||||
watch(nowTime, (newTime) => {
|
||||
const newIndex = getLrcIndex(newTime);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
nowIndex.value = newIndex;
|
||||
currentLrcProgress.value = 0; // 重置进度
|
||||
// 当索引变化时发送更新
|
||||
if (isElectron.value) {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 处理歌曲结束
|
||||
export const handleEnded = () => {
|
||||
// ... 原有的结束处理逻辑 ...
|
||||
|
||||
// 如果有歌词窗口,发送初始化数据
|
||||
if (isElectron.value) {
|
||||
// 延迟一下等待新歌曲加载完成
|
||||
setTimeout(() => {
|
||||
initLyricWindow();
|
||||
sendLyricToWin();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化歌词数据
|
||||
export const 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;
|
||||
|
||||
try {
|
||||
if (lrcArray.value.length > 0) {
|
||||
const nowIndex = getLrcIndex(nowTime.value);
|
||||
const { currentLrc, nextLrc } = getCurrentLrc();
|
||||
const { currentTime, nextTime } = getLrcTimeRange(nowIndex);
|
||||
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
|
||||
const lyricWinData = {
|
||||
currentLrc,
|
||||
nextLrc,
|
||||
currentTime,
|
||||
nextTime,
|
||||
const updateData = {
|
||||
type: 'update',
|
||||
nowIndex,
|
||||
lrcTimeArray: lrcTimeArray.value,
|
||||
lrcArray: lrcArray.value,
|
||||
nowTime: nowTime.value,
|
||||
allTime: allTime.value,
|
||||
startCurrentTime: lrcTimeArray.value[nowIndex],
|
||||
nextTime: lrcTimeArray.value[nowIndex + 1],
|
||||
isPlay,
|
||||
};
|
||||
windowData.electronAPI.sendLyric(JSON.stringify(lyricWinData));
|
||||
windowData.electronAPI.sendLyric(JSON.stringify(updateData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending lyric to window:', error);
|
||||
console.error('Error sending lyric update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const openLyric = () => {
|
||||
if (!isElectron.value) return;
|
||||
console.log('Opening lyric window');
|
||||
windowData.electronAPI.openLyric();
|
||||
sendLyricToWin();
|
||||
|
||||
// 延迟一下初始化,确保窗口已经创建
|
||||
setTimeout(() => {
|
||||
console.log('Initializing lyric window after delay');
|
||||
initLyricWindow();
|
||||
sendLyricToWin();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
@@ -23,9 +23,6 @@ const getSongUrl = async (id: number) => {
|
||||
};
|
||||
|
||||
const getSongDetail = async (playMusic: SongResult) => {
|
||||
if (playMusic.playMusicUrl) {
|
||||
return playMusic;
|
||||
}
|
||||
playMusic.playLoading = true;
|
||||
const playMusicUrl = await getSongUrl(playMusic.id);
|
||||
const { backgroundColor, primaryColor } =
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- 底部音乐播放 -->
|
||||
<play-bar v-if="isPlay" />
|
||||
</div>
|
||||
<install-app-modal></install-app-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,6 +37,7 @@
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import InstallAppModal from '@/components/common/InstallAppModal.vue';
|
||||
import PlayBottom from '@/components/common/PlayBottom.vue';
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
import homeRouter from '@/router/home';
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<div>
|
||||
<div class="music-content-name">{{ playMusic.name }}</div>
|
||||
<div class="music-content-singer">
|
||||
<span v-for="(item, index) in playMusic.song.artists" :key="index">
|
||||
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
|
||||
<span v-for="(item, index) in playMusic.ar || playMusic.song.artists" :key="index">
|
||||
{{ item.name }}{{ index < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@
|
||||
:id="`music-lrc-text-${index}`"
|
||||
:key="index"
|
||||
class="music-lrc-text"
|
||||
:class="{ 'now-text': index === nowIndex }"
|
||||
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
|
||||
@click="setAudioTime(index, audio)"
|
||||
>
|
||||
<span :style="getLrcStyle(index)">{{ item.text }}</span>
|
||||
@@ -220,7 +220,6 @@ defineExpose({
|
||||
|
||||
#drawer-target {
|
||||
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
|
||||
backdrop-filter: blur(20px);
|
||||
animation-duration: 300ms;
|
||||
|
||||
.music-img {
|
||||
@@ -252,6 +251,7 @@ defineExpose({
|
||||
background-color: inherit;
|
||||
width: 500px;
|
||||
height: 550px;
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
|
||||
&-text {
|
||||
@apply text-2xl cursor-pointer font-bold px-2 py-4;
|
||||
transition: all 0.3s ease;
|
||||
@@ -259,11 +259,19 @@ defineExpose({
|
||||
|
||||
span {
|
||||
padding-right: 100px;
|
||||
display: inline-block;
|
||||
// display: inline-block;
|
||||
background-clip: text !important;
|
||||
-webkit-background-clip: text !important;
|
||||
}
|
||||
|
||||
&-tr {
|
||||
@apply font-normal;
|
||||
opacity: 0.7;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.hover-text {
|
||||
&:hover {
|
||||
@apply font-bold opacity-100 rounded-xl;
|
||||
background-color: var(--hover-bg-color);
|
||||
@@ -272,12 +280,6 @@ defineExpose({
|
||||
color: var(--text-color-active) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-tr {
|
||||
@apply font-normal;
|
||||
opacity: 0.7;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@
|
||||
</div>
|
||||
<div class="music-content-name">
|
||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||
<span v-for="(item, index) in playMusic.song.artists" :key="index">
|
||||
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
|
||||
</span>
|
||||
<span v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists" :key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{ artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
/>
|
||||
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
|
||||
</div>
|
||||
<n-tooltip v-if="!isElectron">
|
||||
<template #trigger>
|
||||
<div class="github" @click="toGithub">
|
||||
<i class="ri-github-fill"></i>
|
||||
</div>
|
||||
</template>
|
||||
<div>前往 Github</div>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,6 +53,7 @@ import { useStore } from 'vuex';
|
||||
import { getSearchKeyword } from '@/api/home';
|
||||
import { getUserDetail, logout } from '@/api/login';
|
||||
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
|
||||
import { isElectron } from '@/hooks/MusicHook';
|
||||
import { getImgUrl } from '@/utils';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -135,6 +144,10 @@ const selectItem = async (key: string) => {
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const toGithub = () => {
|
||||
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -154,4 +167,8 @@ const selectItem = async (key: string) => {
|
||||
@apply pl-4;
|
||||
}
|
||||
}
|
||||
|
||||
.github {
|
||||
@apply cursor-pointer text-gray-100 hover:text-gray-400 text-xl ml-4 rounded-full border border-gray-600 flex justify-center items-center px-2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@ import directives from './directive';
|
||||
const app = createApp(App);
|
||||
|
||||
Object.keys(directives).forEach((key: string) => {
|
||||
app.directive(key, directives[key]);
|
||||
app.directive(key, directives[key as keyof typeof directives]);
|
||||
});
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
|
||||
@@ -35,7 +35,6 @@ const state: State = {
|
||||
searchValue: '',
|
||||
searchType: 1,
|
||||
};
|
||||
const windowData = window as any;
|
||||
|
||||
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
|
||||
|
||||
@@ -64,7 +63,9 @@ const mutations = {
|
||||
},
|
||||
async setSetData(state: State, setData: any) {
|
||||
state.setData = setData;
|
||||
window.electron && window.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
||||
if ((window as any).electron) {
|
||||
(window as any).electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface Song {
|
||||
count?: number;
|
||||
playLoading?: boolean;
|
||||
picUrl?: string;
|
||||
ar: Artist[];
|
||||
}
|
||||
|
||||
interface Privilege {
|
||||
|
||||
@@ -54,11 +54,9 @@ export const getIsMc = () => {
|
||||
if (windowData.electron.ipcRenderer.getStoreValue('set').isProxy) {
|
||||
return true;
|
||||
}
|
||||
if (window.location.origin.includes('localhost')) {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const ProxyUrl = import.meta.env.VITE_API_PROXY || 'http://110.42.251.190:9856';
|
||||
const ProxyUrl = import.meta.env.VITE_API_PROXY;
|
||||
|
||||
export const getMusicProxyUrl = (url: string) => {
|
||||
if (!getIsMc()) {
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
>
|
||||
<song-item class="history-item-content" :item="item" @play="handlePlay" />
|
||||
<div class="history-item-count">
|
||||
<song-item class="history-item-content" :item="item" list @play="handlePlay" />
|
||||
<div class="history-item-count min-w-[60px]">
|
||||
{{ item.count }}
|
||||
</div>
|
||||
<div class="history-item-delete">
|
||||
@@ -56,7 +56,7 @@ const handlePlay = () => {
|
||||
@apply flex-1;
|
||||
}
|
||||
&-count {
|
||||
@apply px-4 text-lg;
|
||||
@apply px-4 text-lg text-center;
|
||||
}
|
||||
&-delete {
|
||||
@apply cursor-pointer rounded-full border-2 border-gray-400 w-8 h-8 flex justify-center items-center;
|
||||
|
||||
@@ -11,12 +11,31 @@ defineOptions({
|
||||
name: 'List',
|
||||
});
|
||||
|
||||
const recommendList = ref();
|
||||
const ITEMS_PER_ROW = ref(6); // 每行显示的数量
|
||||
const TOTAL_ITEMS = 30; // 每页数量
|
||||
|
||||
// 计算实际需要加载的数量,确保能被每行数量整除
|
||||
const getAdjustedLimit = (perRow: number) => {
|
||||
return Math.ceil(TOTAL_ITEMS / perRow) * perRow;
|
||||
};
|
||||
|
||||
const recommendList = ref<any[]>([]);
|
||||
const showMusic = ref(false);
|
||||
const 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;
|
||||
@@ -32,31 +51,78 @@ const route = useRoute();
|
||||
const listTitle = ref(route.query.type || '歌单列表');
|
||||
|
||||
const loading = ref(false);
|
||||
const loadList = async (type: string) => {
|
||||
loading.value = true;
|
||||
const params = {
|
||||
cat: type || '',
|
||||
limit: 30,
|
||||
offset: 0,
|
||||
};
|
||||
const { data } = await getListByCat(params);
|
||||
recommendList.value = data.playlists;
|
||||
loading.value = 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;
|
||||
}
|
||||
};
|
||||
|
||||
if (route.query.type) {
|
||||
loadList(route.query.type as string);
|
||||
} else {
|
||||
getRecommendList().then((res: { data: { result: any } }) => {
|
||||
recommendList.value = res.data.result;
|
||||
});
|
||||
}
|
||||
// 监听滚动事件
|
||||
const handleScroll = (e: any) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||
// 距离底部100px时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
|
||||
loadList(route.query.type as string, true);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听窗口大小变化,调整每行显示数量
|
||||
const 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 = null;
|
||||
recommendList.value = [];
|
||||
listTitle.value = newParams.type || '歌单列表';
|
||||
loadList(newParams.type as string);
|
||||
}
|
||||
@@ -68,14 +134,14 @@ watch(
|
||||
<div class="list-page">
|
||||
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
|
||||
<!-- 歌单列表 -->
|
||||
<n-scrollbar class="recommend" :size="100" @click="showMusic = false">
|
||||
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
|
||||
<div v-loading="loading" class="recommend-list">
|
||||
<div
|
||||
v-for="(item, index) in recommendList"
|
||||
:key="item.id"
|
||||
class="recommend-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index, 30)"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
@click.stop="selectRecommendItem(item)"
|
||||
>
|
||||
<div class="recommend-item-img">
|
||||
@@ -95,6 +161,12 @@ watch(
|
||||
<div class="recommend-item-title">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">加载中...</span>
|
||||
</div>
|
||||
<div v-if="!hasMore && recommendList.length > 0" class="no-more">没有更多了</div>
|
||||
</n-scrollbar>
|
||||
<music-list
|
||||
v-model:show="showMusic"
|
||||
@@ -108,7 +180,7 @@ watch(
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-page {
|
||||
@apply relative h-full w-full px-4;
|
||||
@apply relative h-full w-full;
|
||||
}
|
||||
|
||||
.recommend {
|
||||
@@ -118,18 +190,22 @@ watch(
|
||||
}
|
||||
|
||||
&-list {
|
||||
@apply grid gap-6 pb-28;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13%, 1fr));
|
||||
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
|
||||
grid-template-columns: repeat(v-bind(ITEMS_PER_ROW), minmax(0, 1fr));
|
||||
}
|
||||
&-item {
|
||||
@apply flex flex-col;
|
||||
&-img {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
@apply rounded-xl overflow-hidden relative w-full;
|
||||
&-img {
|
||||
@apply block w-full h-full;
|
||||
}
|
||||
img {
|
||||
@apply absolute top-0 left-0 w-full h-full object-cover rounded-xl;
|
||||
}
|
||||
&:hover img {
|
||||
@apply hover:scale-110 transition-all duration-300 ease-in-out;
|
||||
}
|
||||
&-img {
|
||||
@apply h-full w-full rounded-xl overflow-hidden;
|
||||
}
|
||||
.top {
|
||||
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
|
||||
background-color: #00000088;
|
||||
@@ -147,10 +223,7 @@ watch(
|
||||
}
|
||||
|
||||
.play-count {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
font-size: 14px;
|
||||
@apply absolute top-2 left-2 text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,9 +233,11 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.recommend-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(25%, 1fr));
|
||||
}
|
||||
.loading-more {
|
||||
@apply flex items-center justify-center py-4 text-sm text-gray-400;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
@apply text-center py-4 text-sm text-gray-500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,107 @@
|
||||
<template>
|
||||
<div class="lyric-window" :class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]">
|
||||
<div class="drag-bar"></div>
|
||||
<div class="lyric-bar" :class="{ 'lyric-bar-hover': isDrag }">
|
||||
<div class="buttons">
|
||||
<!-- <div class="music-buttons">
|
||||
<div @click="handlePrev">
|
||||
<i class="iconfont icon-prev"></i>
|
||||
</div>
|
||||
<div class="music-buttons-play" @click="playMusicEvent">
|
||||
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
|
||||
</div>
|
||||
<div @click="handleEnded">
|
||||
<i class="iconfont icon-next"></i>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="button check-theme" @click="checkTheme">
|
||||
<i v-if="lyricSetting.theme === 'light'" class="icon ri-sun-line"></i>
|
||||
<i v-else class="icon ri-moon-line"></i>
|
||||
<div
|
||||
class="lyric-window"
|
||||
:class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<!-- 顶部控制栏 -->
|
||||
<div class="control-bar" :class="{ 'control-bar-show': showControls }">
|
||||
<div class="font-size-controls">
|
||||
<n-button-group>
|
||||
<n-button quaternary size="small" :disabled="fontSize <= 12" @click="decreaseFontSize">
|
||||
<i class="ri-subtract-line"></i>
|
||||
</n-button>
|
||||
<n-button quaternary size="small" :disabled="fontSize >= 48" @click="increaseFontSize">
|
||||
<i class="ri-add-line"></i>
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
<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="button">
|
||||
<i class="icon ri-share-2-line" :class="{ checked: lyricSetting.isTop }" @click="handleTop"></i>
|
||||
<div class="control-button" @click="handleTop">
|
||||
<i class="ri-pushpin-line" :class="{ active: lyricSetting.isTop }"></i>
|
||||
</div>
|
||||
<div class="button button-lock" @click="handleLock">
|
||||
<i v-if="lyricSetting.isLock" class="icon ri-lock-line"></i>
|
||||
<i v-else class="icon ri-lock-unlock-line"></i>
|
||||
<div class="control-button" @click="handleLock">
|
||||
<i v-if="lyricSetting.isLock" class="ri-lock-line"></i>
|
||||
<i v-else class="ri-lock-unlock-line"></i>
|
||||
</div>
|
||||
<div class="button">
|
||||
<i class="icon ri-close-circle-line" @click="handleClose"></i>
|
||||
<div class="control-button" @click="handleClose">
|
||||
<i class="ri-close-line"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="clickThroughElement" class="lyric-box">
|
||||
<template v-if="lyricData.lrcArray[lyricData.nowIndex]">
|
||||
<h2 class="lyric lyric-current">{{ lyricData.lrcArray[lyricData.nowIndex].text }}</h2>
|
||||
<p class="lyric-current">{{ lyricData.currentLrc.trText }}</p>
|
||||
<template v-if="lyricData.lrcArray[lyricData.nowIndex + 1]">
|
||||
<h2 class="lyric lyric-next">
|
||||
{{ lyricData.lrcArray[lyricData.nowIndex + 1].text }}
|
||||
</h2>
|
||||
<p class="lyric-next">{{ lyricData.nextLrc.trText }}</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 歌词显示区域 -->
|
||||
<div ref="containerRef" class="lyric-container">
|
||||
<div class="lyric-scroll">
|
||||
<div class="lyric-wrapper" :style="wrapperStyle">
|
||||
<template v-if="staticData.lrcArray?.length > 0">
|
||||
<div
|
||||
v-for="(line, index) in staticData.lrcArray"
|
||||
:key="index"
|
||||
class="lyric-line"
|
||||
:style="lyricLineStyle"
|
||||
:class="{
|
||||
'lyric-line-current': index === currentIndex,
|
||||
'lyric-line-passed': index < currentIndex,
|
||||
'lyric-line-next': index === currentIndex + 1,
|
||||
}"
|
||||
>
|
||||
<div class="lyric-text" :style="{ fontSize: `${fontSize}px` }">
|
||||
<span class="lyric-text-inner" :style="getLyricStyle(index)">
|
||||
{{ line.text || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="line.trText" class="lyric-translation" :style="{ fontSize: `${fontSize * 0.6}px` }">
|
||||
{{ line.trText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="lyric-empty">暂无歌词</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIpcRenderer } from '@vueuse/electron';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'Lyric',
|
||||
});
|
||||
|
||||
const ipcRenderer = useIpcRenderer();
|
||||
const windowData = window as any;
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const containerHeight = ref(0);
|
||||
const lineHeight = ref(60);
|
||||
const currentIndex = ref(0);
|
||||
const isInitialized = ref(false);
|
||||
// 字体大小控制
|
||||
const fontSize = ref(24); // 默认字体大小
|
||||
const fontSizeStep = 2; // 每次整的步长
|
||||
|
||||
const lyricData = ref({
|
||||
currentLrc: {
|
||||
text: '',
|
||||
trText: '',
|
||||
},
|
||||
nextLrc: {
|
||||
text: '',
|
||||
trText: '',
|
||||
},
|
||||
currentTime: 0,
|
||||
nextTime: 0,
|
||||
nowTime: 0,
|
||||
// 静态数据
|
||||
const staticData = ref<{
|
||||
lrcArray: Array<{ text: string; trText: string }>;
|
||||
lrcTimeArray: number[];
|
||||
allTime: number;
|
||||
}>({
|
||||
lrcArray: [],
|
||||
lrcTimeArray: [],
|
||||
allTime: 0,
|
||||
});
|
||||
|
||||
// 动态数据
|
||||
const dynamicData = ref({
|
||||
nowTime: 0,
|
||||
startCurrentTime: 0,
|
||||
lrcArray: [] as any,
|
||||
lrcTimeArray: [] as any,
|
||||
nowIndex: 0,
|
||||
nextTime: 0,
|
||||
isPlay: true,
|
||||
});
|
||||
|
||||
const lyricSetting = ref({
|
||||
@@ -83,16 +114,323 @@ const lyricSetting = ref({
|
||||
}),
|
||||
});
|
||||
|
||||
let hideControlsTimer: number | null = null;
|
||||
|
||||
const isHovering = ref(false);
|
||||
|
||||
// 计算是否栏
|
||||
const showControls = computed(() => {
|
||||
if (lyricSetting.value.isLock) {
|
||||
return isHovering.value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 清除隐藏定时器
|
||||
const clearHideTimer = () => {
|
||||
if (hideControlsTimer) {
|
||||
clearTimeout(hideControlsTimer);
|
||||
hideControlsTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标进入窗口
|
||||
const handleMouseEnter = () => {
|
||||
if (!lyricSetting.value.isLock) return;
|
||||
isHovering.value = true;
|
||||
};
|
||||
|
||||
// 处理鼠标离开窗口
|
||||
const handleMouseLeave = () => {
|
||||
if (!lyricSetting.value.isLock) return;
|
||||
isHovering.value = false;
|
||||
};
|
||||
|
||||
// 监听锁定状态变化
|
||||
watch(
|
||||
() => lyricSetting.value.isLock,
|
||||
(newLock: boolean) => {
|
||||
if (newLock) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ipcRenderer.on('receive-lyric', (event, data) => {
|
||||
// 初始化时,如果是锁定状态,确保控制栏隐藏
|
||||
if (lyricSetting.value.isLock) {
|
||||
isHovering.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearHideTimer();
|
||||
});
|
||||
|
||||
// 计算歌词滚动位置
|
||||
const wrapperStyle = computed(() => {
|
||||
if (!isInitialized.value || !containerHeight.value) {
|
||||
return {
|
||||
transform: 'translateY(0)',
|
||||
transition: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// 计算容器中心点
|
||||
const containerCenter = containerHeight.value / 2;
|
||||
|
||||
// 计算当前行到顶部的距离(包含padding)
|
||||
const currentLineTop = currentIndex.value * lineHeight.value + containerHeight.value * 0.2; // 加上顶部padding
|
||||
|
||||
// 计算偏移量,使当前行居中
|
||||
const targetOffset = containerCenter - currentLineTop;
|
||||
|
||||
// 计算内容总高度(包含padding)
|
||||
const contentHeight = staticData.value.lrcArray.length * lineHeight.value + containerHeight.value * 0.4; // 上下padding各20vh
|
||||
|
||||
// 计算最小和最大偏移量
|
||||
const minOffset = -(contentHeight - containerHeight.value);
|
||||
const maxOffset = 0;
|
||||
|
||||
// 限制偏移量在合理范围内
|
||||
const finalOffset = Math.min(maxOffset, Math.max(minOffset, targetOffset));
|
||||
|
||||
return {
|
||||
transform: `translateY(${finalOffset}px)`,
|
||||
transition: isInitialized.value ? 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
||||
};
|
||||
});
|
||||
|
||||
const lyricLineStyle = computed(() => ({
|
||||
height: `${lineHeight.value}px`,
|
||||
}));
|
||||
// 更新容器高度和行高
|
||||
const updateContainerHeight = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
// 更新容器高度
|
||||
containerHeight.value = containerRef.value.clientHeight;
|
||||
|
||||
// 计算基础行高(字体大小的2.5倍)
|
||||
const baseLineHeight = fontSize.value * 2.5;
|
||||
|
||||
// 计算最大允许行高(容器高度的1/4)
|
||||
const maxAllowedHeight = containerHeight.value / 3;
|
||||
|
||||
// 设置行高(不小于40px,不大于最大允许高度)
|
||||
lineHeight.value = Math.min(maxAllowedHeight, Math.max(40, baseLineHeight));
|
||||
};
|
||||
|
||||
// 处理字体大小变化
|
||||
const handleFontSizeChange = async () => {
|
||||
// 先保存字体大小
|
||||
saveFontSize();
|
||||
|
||||
// 更新容器高度和行高
|
||||
updateContainerHeight();
|
||||
};
|
||||
|
||||
// 增加字体大小
|
||||
const increaseFontSize = async () => {
|
||||
if (fontSize.value < 48) {
|
||||
fontSize.value += fontSizeStep;
|
||||
await handleFontSizeChange();
|
||||
}
|
||||
};
|
||||
|
||||
// 减小字体大小
|
||||
const decreaseFontSize = async () => {
|
||||
if (fontSize.value > 12) {
|
||||
fontSize.value -= fontSizeStep;
|
||||
await handleFontSizeChange();
|
||||
}
|
||||
};
|
||||
|
||||
// 保存字体大小到本地存储
|
||||
const saveFontSize = () => {
|
||||
localStorage.setItem('lyricFontSize', fontSize.value.toString());
|
||||
};
|
||||
|
||||
// 监听容器大小变化
|
||||
onMounted(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateContainerHeight();
|
||||
});
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
// 动画帧ID
|
||||
const animationFrameId = ref<number | null>(null);
|
||||
|
||||
// 实际播放时间
|
||||
const actualTime = ref(0);
|
||||
|
||||
// 计算当前行的进度
|
||||
const currentProgress = computed(() => {
|
||||
const { startCurrentTime, nextTime, isPlay } = dynamicData.value;
|
||||
if (!startCurrentTime || !nextTime || !isPlay) return 0;
|
||||
|
||||
const duration = nextTime - startCurrentTime;
|
||||
const elapsed = actualTime.value - startCurrentTime;
|
||||
return Math.min(Math.max(elapsed / duration, 0), 1);
|
||||
});
|
||||
|
||||
// 获取歌词样式
|
||||
const getLyricStyle = (index: number) => {
|
||||
if (index !== currentIndex.value) return {};
|
||||
|
||||
const progress = currentProgress.value * 100;
|
||||
return {
|
||||
background: `linear-gradient(to right, var(--highlight-color) ${progress}%, var(--text-color) ${progress}%)`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
transition: 'all 0.1s linear',
|
||||
};
|
||||
};
|
||||
|
||||
// 时间偏移量(毫秒)
|
||||
const TIME_OFFSET = 400;
|
||||
|
||||
// 更新动画
|
||||
const updateProgress = () => {
|
||||
if (!dynamicData.value.isPlay) {
|
||||
if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算实际时间,添加偏移量
|
||||
const timeDiff = (performance.now() - lastUpdateTime.value) / 1000;
|
||||
actualTime.value = dynamicData.value.nowTime + timeDiff + TIME_OFFSET / 1000;
|
||||
|
||||
// 继续动画
|
||||
animationFrameId.value = requestAnimationFrame(updateProgress);
|
||||
};
|
||||
|
||||
// 记录上次更新时间
|
||||
const lastUpdateTime = ref(performance.now());
|
||||
|
||||
// 监听数据更新
|
||||
watch(
|
||||
() => dynamicData.value,
|
||||
(newData: any) => {
|
||||
// 更新最后更新时间
|
||||
lastUpdateTime.value = performance.now();
|
||||
|
||||
// 更新实际时间,包含偏移量
|
||||
actualTime.value = newData.nowTime + TIME_OFFSET / 1000;
|
||||
|
||||
// 如果正在播放且没有动画,启动动画
|
||||
if (newData.isPlay && !animationFrameId.value) {
|
||||
updateProgress();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 监听播放状态变化
|
||||
watch(
|
||||
() => dynamicData.value.isPlay,
|
||||
(isPlaying: boolean) => {
|
||||
if (isPlaying) {
|
||||
lastUpdateTime.value = performance.now();
|
||||
updateProgress();
|
||||
} else if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 修改数据更新处理
|
||||
const handleDataUpdate = (parsedData: {
|
||||
nowTime: number;
|
||||
startCurrentTime: number;
|
||||
nextTime: number;
|
||||
isPlay: boolean;
|
||||
nowIndex: number;
|
||||
}) => {
|
||||
// 确保数据存在且格式正确
|
||||
if (!parsedData || typeof parsedData.nowTime !== 'number') {
|
||||
console.error('Invalid update data received:', parsedData);
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicData.value = {
|
||||
nowTime: parsedData.nowTime,
|
||||
startCurrentTime: parsedData.startCurrentTime,
|
||||
nextTime: parsedData.nextTime,
|
||||
isPlay: parsedData.isPlay,
|
||||
};
|
||||
|
||||
// 更新索引
|
||||
if (typeof parsedData.nowIndex === 'number' && parsedData.nowIndex !== currentIndex.value) {
|
||||
currentIndex.value = parsedData.nowIndex;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 加载保存的字体大小
|
||||
const savedFontSize = localStorage.getItem('lyricFontSize');
|
||||
if (savedFontSize) {
|
||||
fontSize.value = Number(savedFontSize);
|
||||
lineHeight.value = fontSize.value * 2.5;
|
||||
}
|
||||
|
||||
// 初始化容器高度
|
||||
updateContainerHeight();
|
||||
window.addEventListener('resize', updateContainerHeight);
|
||||
|
||||
// 监听歌词数据
|
||||
windowData.electron.ipcRenderer.on('receive-lyric', (data: string) => {
|
||||
try {
|
||||
lyricData.value = JSON.parse(data);
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData.type === 'init') {
|
||||
// 初始化重置状态
|
||||
currentIndex.value = 0;
|
||||
isInitialized.value = false;
|
||||
|
||||
// 清理可能存在的动画
|
||||
if (animationFrameId.value) {
|
||||
cancelAnimationFrame(animationFrameId.value);
|
||||
animationFrameId.value = null;
|
||||
}
|
||||
|
||||
// 保据格式正确
|
||||
if (Array.isArray(parsedData.lrcArray)) {
|
||||
staticData.value = {
|
||||
lrcArray: parsedData.lrcArray,
|
||||
lrcTimeArray: parsedData.lrcTimeArray || [],
|
||||
allTime: parsedData.allTime || 0,
|
||||
};
|
||||
} else {
|
||||
console.error('Invalid lyric array format:', parsedData);
|
||||
}
|
||||
nextTick(() => {
|
||||
isInitialized.value = true;
|
||||
});
|
||||
} else if (parsedData.type === 'update') {
|
||||
handleDataUpdate(parsedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
console.error('Error parsing lyric data:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateContainerHeight);
|
||||
});
|
||||
|
||||
const checkTheme = () => {
|
||||
if (lyricSetting.value.theme === 'light') {
|
||||
lyricSetting.value.theme = 'dark';
|
||||
@@ -103,7 +441,7 @@ const checkTheme = () => {
|
||||
|
||||
const handleTop = () => {
|
||||
lyricSetting.value.isTop = !lyricSetting.value.isTop;
|
||||
ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
||||
windowData.electron.ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
|
||||
};
|
||||
|
||||
const handleLock = () => {
|
||||
@@ -111,26 +449,16 @@ const handleLock = () => {
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
ipcRenderer.send('close-lyric');
|
||||
windowData.electron.ipcRenderer.send('close-lyric');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => lyricSetting.value,
|
||||
(newValue) => {
|
||||
(newValue: any) => {
|
||||
localStorage.setItem('lyricData', JSON.stringify(newValue));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// onMounted(() => {
|
||||
// const el = document.getElementById('clickThroughElement') as HTMLElement;
|
||||
// el.addEventListener('mouseenter', () => {
|
||||
// if (lyricSetting.value.isLock) ipcRenderer.send('mouseenter-lyric');
|
||||
// });
|
||||
// el.addEventListener('mouseleave', () => {
|
||||
// if (lyricSetting.value.isLock) ipcRenderer.send('mouseleave-lyric');
|
||||
// });
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -143,132 +471,196 @@ body {
|
||||
.lyric-window {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@apply overflow-hidden text-gray-600 rounded-xl box-border;
|
||||
// border: 4px solid transparent;
|
||||
&:hover .lyric-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover .drag-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 10px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
.lyric_lock {
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
&.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);
|
||||
}
|
||||
&:hover .lyric-bar {
|
||||
background-color: transparent;
|
||||
.button {
|
||||
opacity: 0;
|
||||
}
|
||||
.button-lock {
|
||||
opacity: 1;
|
||||
color: #d6d6d6;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover .drag-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@apply text-xl hover:text-white;
|
||||
}
|
||||
|
||||
.lyric-bar {
|
||||
background-color: #b1b1b1;
|
||||
@apply flex flex-col justify-center items-center;
|
||||
width: 100vw;
|
||||
.control-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: var(--control-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
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 {
|
||||
margin-right: auto; // 将字体控制放在侧
|
||||
padding-right: 20px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.n-button {
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
&:hover {
|
||||
background: var(--control-bg);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&.active {
|
||||
color: var(--highlight-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-container {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lyric-scroll {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.lyric-wrapper {
|
||||
will-change: transform;
|
||||
padding: 20vh 0;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.lyric-line {
|
||||
padding: 4px 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&.lyric-line-current {
|
||||
transform: scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.lyric-line-passed,
|
||||
&.lyric-line-next {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
.lyric-bar-hover {
|
||||
|
||||
.lyric-text {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: var(--text-color);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.lyric-translation {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
transition: font-size 0.2s ease;
|
||||
line-height: 1.4; // 添加行高比例
|
||||
}
|
||||
|
||||
.lyric-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: transparent !important;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.lyric-content {
|
||||
transition: font-size 0.2s ease;
|
||||
}
|
||||
|
||||
.lyric-line-current {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-bar {
|
||||
-webkit-app-region: drag;
|
||||
height: 20px;
|
||||
cursor: move;
|
||||
background-color: #383838;
|
||||
opacity: 0;
|
||||
}
|
||||
.buttons {
|
||||
width: 100vw;
|
||||
height: 100px;
|
||||
@apply flex justify-center items-center gap-4;
|
||||
}
|
||||
.button {
|
||||
@apply cursor-pointer text-center;
|
||||
}
|
||||
.checked {
|
||||
color: #fff !important;
|
||||
}
|
||||
.button-move {
|
||||
-webkit-app-region: drag;
|
||||
cursor: move;
|
||||
}
|
||||
.music-buttons {
|
||||
@apply mx-6;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.iconfont {
|
||||
@apply text-2xl hover:text-green-500 transition;
|
||||
}
|
||||
|
||||
@apply flex items-center;
|
||||
|
||||
> div {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
&-play {
|
||||
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
|
||||
background: #383838;
|
||||
}
|
||||
}
|
||||
.check-theme {
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lyric {
|
||||
text-shadow: 0 0 1vw #2c2c2c;
|
||||
font-size: 4vw;
|
||||
@apply font-bold m-0 p-0 select-none pointer-events-none;
|
||||
}
|
||||
|
||||
.lyric-current {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.lyric-next {
|
||||
color: #999;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.lyric-window.dark {
|
||||
.lyric {
|
||||
text-shadow: none;
|
||||
text-shadow: 0 0 1vw #000000;
|
||||
}
|
||||
.lyric-current {
|
||||
color: #fff;
|
||||
}
|
||||
.lyric-next {
|
||||
color: #cecece;
|
||||
}
|
||||
}
|
||||
.lyric-box {
|
||||
// writing-mode: vertical-rl;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:key="item.id"
|
||||
class="mv-item"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="setAnimationDelay(index, 10)"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
>
|
||||
<div class="mv-item-img" @click="handleShowMv(item, index)">
|
||||
<n-image
|
||||
@@ -68,6 +68,11 @@ const offset = ref(0);
|
||||
const limit = ref(30);
|
||||
const hasMore = ref(true);
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % limit.value;
|
||||
return setAnimationDelay(currentPageIndex, 30);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMvList();
|
||||
});
|
||||
@@ -157,14 +162,14 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mv-list {
|
||||
@apply relative h-full w-full px-4;
|
||||
@apply relative h-full w-full;
|
||||
|
||||
&-title {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply grid gap-6 pb-28 mt-2;
|
||||
@apply grid gap-6 pb-28 mt-2 pr-4;
|
||||
grid-template-columns: repeat(auto-fill, minmax(14%, 1fr));
|
||||
}
|
||||
|
||||
|
||||
@@ -57,12 +57,18 @@ onActivated(() => {
|
||||
|
||||
const isShowList = ref(false);
|
||||
const list = ref<Playlist>();
|
||||
const listLoading = ref(false);
|
||||
// 展示歌单
|
||||
const showPlaylist = async (id: number) => {
|
||||
const showPlaylist = async (id: number, name: string) => {
|
||||
isShowList.value = true;
|
||||
list.value = {};
|
||||
listLoading.value = true;
|
||||
|
||||
list.value = {
|
||||
name,
|
||||
} as Playlist;
|
||||
const { data } = await getListDetail(id);
|
||||
list.value = data.playlist;
|
||||
listLoading.value = false;
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
@@ -103,7 +109,12 @@ const handlePlay = () => {
|
||||
<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)">
|
||||
<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>
|
||||
@@ -133,7 +144,13 @@ const handlePlay = () => {
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
<music-list v-model:show="isShowList" :name="list?.name || ''" :song-list="list?.tracks || []" :list-info="list" />
|
||||
<music-list
|
||||
v-model:show="isShowList"
|
||||
:name="list?.name || ''"
|
||||
:song-list="list?.tracks || []"
|
||||
:list-info="list"
|
||||
:loading="listLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: false, // or 'media' or 'class'
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
@@ -36,15 +36,20 @@ export default defineConfig({
|
||||
port: 4488,
|
||||
proxy: {
|
||||
// with options
|
||||
'/api': {
|
||||
target: 'http://110.42.251.190:9898',
|
||||
[process.env.VITE_API_PROXY as string]: {
|
||||
target: process.env.VITE_API,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY}`), ''),
|
||||
},
|
||||
'/music': {
|
||||
target: 'http://110.42.251.190:4100',
|
||||
[process.env.VITE_API_MUSIC_PROXY as string]: {
|
||||
target: process.env.VITE_API_MUSIC,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/music/, ''),
|
||||
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_MUSIC_PROXY}`), ''),
|
||||
},
|
||||
[process.env.VITE_API_PROXY_MUSIC as string]: {
|
||||
target: process.env.VITE_API_PROXY,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(new RegExp(`^${process.env.VITE_API_PROXY_MUSIC}`), ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user