Compare commits

...

18 Commits

Author SHA1 Message Date
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
35 changed files with 1281 additions and 498 deletions

View File

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

View File

@@ -16,6 +16,8 @@
## 预览地址
[http://mc.alger.fun/](http://mc.alger.fun/)
QQ群:789288579
## 软件截图
![首页](./docs/img/image-7.png)
![歌词](./docs/img/image-6.png)

2
app.js
View File

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

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

@@ -5,16 +5,17 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<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="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" />
@@ -22,12 +23,12 @@
<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" />
@@ -45,37 +46,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>
<!-- 收款码图片预加载 -->
<link rel="preload" as="image" href="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" />
<link rel="preload" as="image" href="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" />
<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>
<!-- 结构化数据 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "网抑云音乐",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Web, Windows, MacOS",
"author": {
"@type": "Person",
"name": "AlgerKong",
"url": "https://github.com/algerkong"
},
"description": "一款免费的在线音乐播放器,支持在线播放、歌词显示、音乐下载等功能。",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "CNY"
}
}
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "alger-music",
"version": "2.1.0",
"version": "2.4.0",
"description": "这是一个用于音乐播放的应用程序。",
"author": "Alger <algerkc@qq.com>",
"main": "app.js",

View File

@@ -1,8 +1,10 @@
<template>
<div class="app-container" :class="{ mobile: isMobile }">
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
<n-config-provider :theme="darkTheme">
<n-dialog-provider>
<router-view></router-view>
<n-message-provider>
<router-view></router-view>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</div>
@@ -12,12 +14,20 @@
import { darkTheme } from 'naive-ui';
import { onMounted } from 'vue';
import { isElectron } from '@/hooks/MusicHook';
import homeRouter from '@/router/home';
import store from '@/store';
import { isMobile } from './utils';
onMounted(() => {
store.dispatch('initializeSettings');
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

View File

@@ -13,16 +13,22 @@
</template>
<div class="p-6 bg-black rounded-lg shadow-lg">
<div class="flex gap-6">
<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" preview-disabled />
<n-image :src="alipayQR" alt="支付宝收款码" class="w-32 h-32 rounded-lg cursor-none" preview-disabled />
<span class="text-sm text-gray-100">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image :src="wechatQR" alt="微信收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<n-image :src="wechatQR" alt="微信收款码" class="w-32 h-32 rounded-lg cursor-none" preview-disabled />
<span class="text-sm text-gray-100">微信支付</span>
</div>
</div>
<div class="mt-4">
<p class="text-sm text-gray-100 text-center cursor-pointer hover:text-green-500" @click="copyQQ">
QQ群789288579
</p>
</div>
</div>
</n-popover>
</div>
@@ -31,6 +37,12 @@
<script setup>
import { NButton, NImage, NPopover } from 'naive-ui';
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
message.success('已复制到剪贴板');
};
defineProps({
alipayQR: {
type: String,

View File

@@ -1,11 +1,12 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100vh' : '80vh'"
:height="isMobile ? '100%' : '80%'"
placement="bottom"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
:to="`#layout-main`"
@mask-click="close"
>
<div class="music-page">
@@ -24,7 +25,7 @@
<div class="music-info">
<div class="music-cover">
<n-image
:src="getImgUrl(listInfo?.coverImgUrl, '300y300')"
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '300y300')"
class="cover-img"
preview-disabled
:class="setAnimationClass('animate__fadeIn')"
@@ -46,21 +47,23 @@
<!-- 右侧歌曲列表 -->
<div class="music-list-container">
<div v-loading="loading" class="music-list">
<div class="music-list">
<n-scrollbar @scroll="handleScroll">
<div v-loading="loading || !songList.length" 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" />
<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>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<play-bottom />
</div>
</n-spin>
</n-scrollbar>
</div>
<play-bottom />
@@ -81,22 +84,31 @@ 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(() => {
@@ -165,6 +177,7 @@ const loadMoreSongs = async () => {
console.error('加载歌曲失败:', error);
} finally {
isLoadingMore.value = false;
loadingList.value = false;
}
};
@@ -184,6 +197,16 @@ const handleScroll = (e: Event) => {
}
};
watch(
() => props.show,
(newVal) => {
loadingList.value = newVal;
if (!props.cover) {
loadingList.value = false;
}
},
);
// 监听 songList 变化,重置分页状态
watch(
() => props.songList,
@@ -193,6 +216,7 @@ watch(
if (newSongs.length > pageSize) {
page.value = 1;
}
loadingList.value = false;
},
{ immediate: true },
);
@@ -253,6 +277,10 @@ watch(
&-list {
@apply flex-grow min-h-0;
&-content {
@apply min-h-[calc(80vh-60px)];
}
:deep(.n-virtual-list__scroll) {
scrollbar-width: none;
&::-webkit-scrollbar {

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
@@ -553,8 +553,8 @@ const isMobile = computed(() => store.state.isMobile);
// 添加横屏模式支持
@media screen and (orientation: landscape) {
height: 100vh !important;
width: 100vw !important;
height: 100% !important;
width: 100% !important;
}
.video-container {
@@ -617,8 +617,8 @@ const isMobile = computed(() => store.state.isMobile);
&:-moz-full-screen,
&:-ms-fullscreen {
background: black;
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
// 确保全屏时标题栏正确显示
.mv-detail-title {

View File

@@ -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(() => {

View File

@@ -53,6 +53,7 @@
v-model:show="showMusic"
name="每日推荐列表"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
</div>
</n-scrollbar>

View File

@@ -6,7 +6,7 @@
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">Alger Music</h2>
<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>
@@ -15,6 +15,15 @@
<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>
@@ -22,6 +31,9 @@
<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);
@@ -33,32 +45,9 @@ const closeModal = () => {
}
};
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;
// }
const downloadUrl = 'https://github.com/algerkong/AlgerMusicPlayer/releases';
if (downloadUrl) {
window.open(downloadUrl, '_blank');
}
};
onMounted(() => {
// 如果是 electron 环境,不显示安装提示
if (isElectron.value) {
if (isElectron.value || isMobile.value) {
return;
}
@@ -69,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>
@@ -77,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;
}
@@ -97,7 +109,7 @@ onMounted(() => {
}
}
.modal-actions {
@apply flex gap-3;
@apply flex gap-3 mt-4;
.n-button {
@apply flex-1;
}

View File

@@ -17,8 +17,15 @@ export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound());
export const isLyricWindowOpen = ref(false); // 新增状态
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) {
@@ -47,13 +54,18 @@ watch(
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,
},
);
@@ -70,8 +82,13 @@ export const audioServiceOn = (audio: typeof audioService) => {
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0;
// 当歌词索引更新时,发送歌词数据
if (isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin();
}
}
if (isElectron.value) {
// 定期发送歌词数据更新
if (isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin();
}
}, 50);
@@ -81,12 +98,21 @@ export const audioServiceOn = (audio: typeof audioService) => {
audio.onPause(() => {
store.commit('setPlayMusic', false);
clearInterval(interval);
// 暂停时也发送一次状态更新
if (isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin();
}
});
// 监听结束
audio.onEnd(() => {
handleEnded();
store.commit('nextPlay');
if (store.state.playMode === 1) {
// 单曲循环模式
audio.getCurrentSound()?.play();
} else {
// 列表循环模式
store.commit('nextPlay');
}
});
};
@@ -201,7 +227,7 @@ export const useLyricProgress = () => {
};
};
// 设置前播放时间
// 设置<EFBFBD><EFBFBD><EFBFBD>前播放时间
export const setAudioTime = (index: number) => {
const currentSound = sound.value;
if (!currentSound) return;
@@ -229,72 +255,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);
}
});
// 处理歌曲结束
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));
}
@@ -305,13 +292,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

@@ -10,6 +10,10 @@
width: 100%;
}
.n-slider-handle-indicator--top {
@apply bg-transparent text-[#ffffffdd] text-2xl px-2 py-1 shadow-none mb-0 !important;
}
.text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
}

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" :style="{ background: backgroundColor }">
<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" />

View File

@@ -1,9 +1,10 @@
<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>

View File

@@ -2,10 +2,14 @@
<!-- 展开全屏 -->
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
<!-- 底部播放栏 -->
<div
class="music-play-bar"
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
>
<div class="music-time custom-slider">
<n-slider v-model:value="timeSlider" :step="1" :max="allTime" :min="0" :format-tooltip="formatTooltip"></n-slider>
</div>
<div class="play-bar-img-wrapper" @click="setMusicFull">
<n-image :src="getImgUrl(playMusic?.picUrl, '300y300')" class="play-bar-img" lazy preview-disabled />
<div class="hover-arrow">
@@ -38,37 +42,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>
@@ -112,7 +117,7 @@ import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, isElectron, nowTime, openLyric, sound } from '@/hooks/MusicHook';
import { allTime, isElectron, isLyricWindowOpen, nowTime, openLyric, sound } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
@@ -139,17 +144,33 @@ watch(
// 使用 useThrottleFn 创建节流版本的 seek 函数
const throttledSeek = useThrottleFn((value: number) => {
if (!sound.value) return;
sound.value.seek((value * allTime.value) / 100);
sound.value.seek(value);
nowTime.value = value;
}, 50); // 50ms 的节流延迟
// 修改 timeSlider 计算属性
const timeSlider = computed({
get: () => (nowTime.value / allTime.value) * 100,
get: () => nowTime.value,
set: throttledSeek,
});
const formatTooltip = (value: number) => {
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
};
// 音量条
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) => {
@@ -159,17 +180,31 @@ const volumeSlider = computed({
audioVolume.value = value / 100;
},
});
// 获取当前播放时间
const getNowTime = computed(() => {
return secondToMinute(nowTime.value);
// 静音
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
// 播放模式
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 ? '列表循环' : '单曲循环';
});
// 获取总时间
const getAllTime = computed(() => {
return secondToMinute(allTime.value);
});
// 切换播放模式
const togglePlayMode = () => {
store.commit('togglePlayMode');
};
function handleEnded() {
function handleNext() {
store.commit('nextPlay');
}
@@ -209,6 +244,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>
@@ -217,13 +269,13 @@ 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;
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 {
@@ -246,14 +298,14 @@ 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;
}
.icon {
@apply text-xl hover:text-white;
@apply text-3xl hover:text-white;
}
@apply flex items-center;
@@ -263,25 +315,28 @@ 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;
background-color: #ffffff20;
@apply flex justify-center items-center w-20 h-12 rounded-full mx-4 hover:bg-[#ffffff40] transition;
}
}
.audio-volume {
width: 140px;
@apply flex items-center mx-4;
@apply flex items-center relative;
&:hover {
.volume-slider {
@apply opacity-100 visible;
}
}
.volume-icon {
@apply cursor-pointer;
.iconfont {
@apply text-2xl hover:text-green-500 transition cursor-pointer mr-4;
.iconfont {
@apply text-2xl hover:text-green-500 transition;
}
}
.volume-slider {
@apply absolute opacity-0 invisible transition-all duration-300 bottom-[30px] left-1/2 -translate-x-1/2 h-[180px] px-2 py-4 bg-gray-800 bg-opacity-80 rounded-xl;
}
}
@@ -349,17 +404,31 @@ const scrollToPlayList = (val: boolean) => {
--n-handle-size: 12px;
--n-handle-color: var(--primary-color);
&: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;
}
.n-slider-handle {
@apply transition-opacity duration-200;
@apply transition-all duration-200;
opacity: 0;
}
@@ -407,4 +476,21 @@ const scrollToPlayList = (val: boolean) => {
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}
.like-active {
@apply text-red-600;
}
.icon-loop,
.icon-single-loop {
font-size: 1.5rem;
}
.music-time .n-slider {
position: absolute;
top: 0;
left: 0;
padding: 0;
border-radius: 0;
}
</style>

View File

@@ -13,32 +13,53 @@
<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>
<coffee
alipay-q-r="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true"
wechat-q-r="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true"
>
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
<template #trigger>
<div class="user-box">
<n-avatar
v-if="store.state.user"
class="ml-2 cursor-pointer"
circle
size="medium"
:src="getImgUrl(store.state.user.avatarUrl)"
@click="selectItem('user')"
/>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
</div>
</template>
<div class="user-popover">
<div v-if="store.state.user" class="user-header" @click="selectItem('user')">
<n-avatar circle size="small" :src="getImgUrl(store.state.user?.avatarUrl)" />
<span class="username">{{ store.state.user?.nickname || 'Theodore' }}</span>
</div>
<div class="menu-items">
<div v-if="!store.state.user" class="menu-item" @click="toLogin">
<i class="iconfont ri-login-box-line"></i>
<span>去登录</span>
</div>
<div class="menu-item" @click="selectItem('set')">
<i class="iconfont ri-settings-3-line"></i>
<span>设置</span>
</div>
<div class="menu-item" @click="toGithubRelease">
<i class="iconfont ri-refresh-line"></i>
<span>当前版本</span>
<span class="download-btn">{{ config.version }}</span>
</div>
</div>
</div>
</n-popover>
<coffee :alipay-q-r="alipay" :wechat-q-r="wechat">
<div class="github" @click="toGithub">
<i class="ri-github-fill"></i>
</div>
@@ -50,8 +71,11 @@
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import config from '@/../package.json';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { getImgUrl } from '@/utils';
@@ -141,6 +165,9 @@ const selectItem = async (key: string) => {
case 'set':
router.push('/set');
break;
case 'user':
router.push('/user');
break;
default:
}
};
@@ -148,11 +175,15 @@ 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;
@apply ml-4 flex text-lg justify-center items-center rounded-full border border-gray-600 hover:border-gray-400 transition-colors duration-200;
background: #1a1a1a;
}
.search-box {
@@ -171,4 +202,65 @@ 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 h-full;
}
.user-popover {
@apply min-w-[280px] p-0 rounded-xl overflow-hidden;
background: #2c2c2c;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
.user-header {
@apply flex items-center gap-2 p-3;
border-bottom: 1px solid #3a3a3a;
.username {
@apply text-sm font-medium text-gray-200;
}
}
.menu-items {
@apply py-1;
.menu-item {
@apply flex items-center px-3 py-2 text-sm cursor-pointer;
@apply text-gray-300;
transition: background-color 0.2s;
&:hover {
background-color: #3a3a3a;
}
i {
@apply mr-1 text-lg text-gray-400;
}
.shortcut {
@apply ml-auto text-xs text-gray-500;
}
.download-btn {
@apply ml-auto px-2 py-0.5 text-xs rounded;
background: #4a4a4a;
color: #fff;
}
.zoom-controls {
@apply ml-auto flex items-center gap-2;
color: #fff;
.zoom-btn {
@apply px-2 py-0.5 text-sm rounded cursor-pointer;
background: #3a3a3a;
&:hover {
background: #4a4a4a;
}
}
span:not(.zoom-btn) {
color: #fff;
}
}
}
}
}
</style>

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>

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,27 +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: '/favorite',
name: 'favorite',
component: () => import('@/views/favorite/index.vue'),
meta: {
title: '我的收藏',
icon: 'icon-likefill',
},
},
{
path: '/user',
@@ -67,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

@@ -13,6 +13,11 @@ const defaultSettings = {
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[];
play: boolean;
@@ -28,6 +33,7 @@ interface State {
searchValue: string;
searchType: number;
favoriteList: number[];
playMode: number;
}
const state: State = {
@@ -36,7 +42,7 @@ 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: defaultSettings,
@@ -44,7 +50,8 @@ const state: State = {
isMobile: false,
searchValue: '',
searchType: 1,
favoriteList: localStorage.getItem('favoriteList') ? JSON.parse(localStorage.getItem('favoriteList') || '[]') : [],
favoriteList: getLocalStorageItem('favoriteList', []),
playMode: getLocalStorageItem('playMode', 0),
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
@@ -91,6 +98,10 @@ const mutations = {
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
},
togglePlayMode(state: State) {
state.playMode = state.playMode === 0 ? 1 : 0;
localStorage.setItem('playMode', JSON.stringify(state.playMode));
},
};
const actions = {

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

@@ -1,45 +1,35 @@
<template>
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
<div class="favorite-header" :class="setAnimationClass('animate__fadeInRight')">
<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 class="favorite-content">
<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">
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<template v-else>
<song-item
v-for="(song, index) in favoriteSongs"
:key="song.id"
:item="song"
:favorite="!isComponent"
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
@play="handlePlay"
/>
</template>
<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 v-if="favoriteList.length > 0 && !loading && !isComponent" class="pagination-wrapper">
<n-pagination
v-model:page="currentPage"
:page-size="pageSize"
:item-count="favoriteList.length"
:page-slot="5"
size="small"
@update:page="handlePageChange"
/>
</div>
</div>
</div>
</template>
@@ -58,12 +48,14 @@ 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);
defineProps({
const props = defineProps({
isComponent: {
type: Boolean,
default: false,
@@ -72,7 +64,6 @@ defineProps({
// 获取当前页的收藏歌曲ID
const getCurrentPageIds = () => {
// 反转列表顺序,最新收藏的在前面
const reversedList = [...favoriteList.value];
const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize;
@@ -86,17 +77,29 @@ const getFavoriteSongs = async () => {
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) {
favoriteSongs.value = res.data.songs.map((song: SongResult) => {
return {
...song,
picUrl: song.al?.picUrl || '',
};
});
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);
@@ -105,9 +108,15 @@ const getFavoriteSongs = async () => {
}
};
// 处理页码变化
const handlePageChange = () => {
getFavoriteSongs();
// 处理滚动事件
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(() => {
@@ -119,6 +128,7 @@ watch(
favoriteList,
() => {
currentPage.value = 1;
noMore.value = false;
getFavoriteSongs();
},
{ deep: true, immediate: true },
@@ -129,8 +139,7 @@ const handlePlay = () => {
};
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 30);
return setAnimationDelay(index, 30);
};
const router = useRouter();
@@ -141,13 +150,13 @@ const handleMore = () => {
<style lang="scss" scoped>
.favorite-page {
@apply h-full flex flex-col p-6;
@apply h-full flex flex-col pt-2;
.favorite-header {
@apply flex items-center justify-between mb-6 flex-shrink-0;
@apply flex items-center justify-between flex-shrink-0 px-4;
h2 {
@apply text-2xl font-bold;
@apply text-xl font-bold pb-2;
}
.favorite-count {
@@ -166,7 +175,7 @@ const handleMore = () => {
}
.favorite-list {
@apply space-y-2 pb-4;
@apply space-y-2 pb-4 px-4;
}
}
}
@@ -176,20 +185,8 @@ const handleMore = () => {
@apply flex justify-center items-center py-20;
}
.pagination-wrapper {
@apply flex justify-center py-4 flex-shrink-0;
:deep(.n-pagination) {
@apply bg-gray-800 rounded-full px-4 py-1;
.n-pagination-item {
@apply text-gray-300 hover:text-white;
&--active {
@apply text-green-500;
}
}
}
.no-more-tip {
@apply text-center text-gray-400 py-4 text-sm;
}
.mobile {

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,9 +44,81 @@ 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>
@@ -45,11 +126,11 @@ const handlePlay = () => {
.history-page {
@apply h-full w-full pt-2;
.title {
@apply pl-4 text-xl font-bold;
@apply pl-4 text-xl font-bold pb-2 px-4;
}
.history-list-content {
@apply px-4 mt-2 pb-28;
@apply mt-2 pb-28 px-4;
.history-item {
@apply flex items-center justify-between;
&-content {
@@ -64,4 +145,12 @@ const handlePlay = () => {
}
}
}
.loading-wrapper {
@apply flex justify-center items-center py-8;
}
.no-more-tip {
@apply text-center text-gray-400 py-4 text-sm;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex gap-4 h-full p-4">
<favorite class="flex-1 bg-[#0d0d0d] border border-[#374151] rounded-2xl overflow-hidden" />
<history class="flex-1 bg-[#0d0d0d] border border-[#374151] rounded-2xl overflow-hidden" />
</div>
</template>
<script setup lang="ts">
import Favorite from '@/views/favorite/index.vue';
import History from '@/views/history/index.vue';
</script>
<style scoped></style>

View File

@@ -47,10 +47,13 @@ defineOptions({
:deep(.favorite-page) {
@apply p-0 mx-4 h-[300px];
.favorite-header {
@apply mb-0;
@apply mb-0 px-0 !important;
h2 {
@apply text-lg font-bold mb-4;
@apply text-lg font-bold;
}
}
.favorite-list {
@apply px-0 !important;
}
}
</style>

View File

@@ -1,10 +1,12 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { getListByCat, getListDetail, getRecommendList } from '@/api/list';
import { getPlaylistCategory } from '@/api/home';
import { getListByCat, getListDetail } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import type { IPlayListSort } from '@/type/playlist';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
@@ -85,14 +87,40 @@ const handleScroll = (e: any) => {
}
};
// 添加歌单分类相关的代码
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(() => {
if (route.query.type) {
loadList(route.query.type as string);
} else {
getRecommendList(TOTAL_ITEMS).then((res: { data: { result: any } }) => {
recommendList.value = res.data.result;
});
}
loadPlaylistCategory(); // 添加加载歌单分类
currentType.value = (route.query.type as string) || currentType.value;
loadList(currentType.value);
});
watch(
@@ -101,6 +129,8 @@ watch(
if (newParams.type) {
recommendList.value = [];
listTitle.value = newParams.type || '歌单列表';
currentType.value = newParams.type as string;
loading.value = true;
loadList(newParams.type as string);
}
},
@@ -109,7 +139,23 @@ watch(
<template>
<div class="list-page">
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
<!-- 修改歌单分类部分 -->
<div class="play-list-type">
<n-scrollbar x-scrollable>
<div class="categories-wrapper">
<span
v-for="(item, index) in playlistCategory?.sub"
:key="item.name"
class="play-list-type-item"
:class="[setAnimationClass('animate__bounceIn'), { active: currentType === item.name }]"
:style="getAnimationDelay(index)"
@click="handleClickPlaylistType(item.name)"
>
{{ item.name }}
</span>
</div>
</n-scrollbar>
</div>
<!-- 歌单列表 -->
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
<div v-loading="loading" class="recommend-list">
@@ -228,4 +274,35 @@ watch(
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
// 添加歌单分类样式
.play-list-type {
.title {
@apply text-lg font-bold mb-4;
}
.categories-wrapper {
@apply flex items-center py-2 pb-4;
white-space: nowrap;
}
&-item {
@apply py-2 px-3 mr-3 inline-block border border-gray-700 rounded-xl cursor-pointer transition-all duration-300;
background-color: #1a1a1a;
&:hover {
@apply bg-green-600/50;
}
&.active {
@apply bg-green-600 border-green-500;
}
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>

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

@@ -3,6 +3,22 @@
<div class="mv-list-title">
<h2>推荐MV</h2>
</div>
<div class="play-list-type">
<n-scrollbar x-scrollable>
<div class="categories-wrapper">
<span
v-for="(category, index) in categories"
:key="category.value"
class="play-list-type-item"
:class="[setAnimationClass('animate__bounceIn'), { active: selectedCategory === category.value }]"
:style="getAnimationDelay(index)"
@click="selectedCategory = category.value"
>
{{ category.label }}
</span>
</div>
</n-scrollbar>
</div>
<n-scrollbar :size="100" @scroll="handleScroll">
<div v-loading="initLoading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div
@@ -10,7 +26,7 @@
: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, '320y180')" lazy preview-disabled />
@@ -38,10 +54,10 @@
</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';
@@ -62,10 +78,26 @@ const offset = ref(0);
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();
@@ -116,26 +148,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;
@@ -157,12 +189,37 @@ 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 pb-2;
}
// 添加歌单分类样式
.play-list-type {
.title {
@apply text-lg font-bold mb-4;
}
.categories-wrapper {
@apply flex items-center py-2 pb-4;
white-space: nowrap;
}
&-item {
@apply py-2 px-3 mr-3 inline-block border border-gray-700 rounded-xl cursor-pointer transition-all duration-300;
background-color: #1a1a1a;
&:hover {
@apply bg-green-600/50;
}
&.active {
@apply bg-green-600 border-green-500;
}
}
}
&-content {
@apply grid gap-4 pb-28 mt-2 pr-4;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));

View File

@@ -1,6 +1,6 @@
<template>
<n-scrollbar>
<div class="set-page">
<div class="set-page" :class="setAnimationClass('animate__bounceInLeft')">
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">代理</div>
@@ -59,16 +59,20 @@
isElectron ? '保存并重启' : '保存'
}}</n-button>
</div>
<div class="p-6 bg-black rounded-lg shadow-lg mt-20">
<div class="mt-10">
<p class="text-sm text-gray-100 text-center cursor-pointer hover:text-green-500" @click="copyQQ">
QQ群789288579
</p>
</div>
<div class="p-6 bg-black rounded-lg shadow-lg">
<div class="text-gray-100 text-base text-center">支持作者</div>
<div class="flex gap-60">
<div class="flex flex-col items-center gap-2 cursor-pointer hover:scale-[2] transition-all z-10 bg-black">
<n-image :src="alipayQR" alt="支付宝收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<div class="flex flex-col items-center gap-2 cursor-none hover:scale-[2] transition-all z-10 bg-black">
<n-image :src="alipay" alt="支付宝收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<span class="text-sm text-gray-100">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2 cursor-pointer hover:scale-[2] transition-all z-10 bg-black">
<n-image :src="wechatQR" alt="微信收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<div class="flex flex-col items-center gap-2 cursor-none hover:scale-[2] transition-all z-10 bg-black">
<n-image :src="wechat" alt="微信收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<span class="text-sm text-gray-100">微信支付</span>
</div>
</div>
@@ -82,14 +86,19 @@ import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import config from '@/../package.json';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
import store from '@/store';
import { setAnimationClass } from '@/utils';
defineOptions({
name: 'Setting',
});
const alipayQR = 'https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true';
const wechatQR = 'https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true';
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
message.success('已复制到剪贴板');
};
const isElectron = ref((window as any).electronAPI !== undefined);
const router = useRouter();
@@ -109,7 +118,7 @@ const handleSave = () => {
if (isElectron.value) {
(window as any).electronAPI.restart();
}
router.back();
router.push('/');
};
const openAuthor = () => {

View File

@@ -1,8 +1,14 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
content: ['./src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
extend: {
colors: {
highlight: 'var(--highlight-color)',
text: 'var(--text-color)',
secondary: 'var(--text-secondary)',
},
},
},
plugins: [],
};