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": { "rules": {
"no-nested-ternary": "off",
"no-console": "off", "no-console": "off",
"no-continue": "off", "no-continue": "off",
"no-restricted-syntax": "off", "no-restricted-syntax": "off",

View File

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

2
app.js
View File

@@ -66,7 +66,7 @@ function createWindow() {
store.set('set', setJson); 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 path = require('path');
const Store = require('electron-store');
const config = require('./config'); const config = require('./config');
const store = new Store();
let lyricWindow = null; let lyricWindow = null;
const createWin = () => { 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({ lyricWindow = new BrowserWindow({
width: 800, width: width || 800,
height: 300, height: height || 200,
x: validPosition ? x : undefined,
y: validPosition ? y : undefined,
frame: false, frame: false,
show: false, show: false,
transparent: true, transparent: true,
hasShadow: false, hasShadow: false,
alwaysOnTop: true,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@@ -19,16 +36,26 @@ const createWin = () => {
webSecurity: false, webSecurity: false,
}, },
}); });
// 监听窗口关闭事件
lyricWindow.on('closed', () => {
console.log('Lyric window closed');
lyricWindow = null;
});
}; };
const loadLyricWindow = (ipcMain) => { const loadLyricWindow = (ipcMain, mainWin) => {
ipcMain.on('open-lyric', () => { ipcMain.on('open-lyric', () => {
console.log('Received open-lyric request');
if (lyricWindow) { if (lyricWindow) {
console.log('Lyric window exists, focusing');
if (lyricWindow.isMinimized()) lyricWindow.restore(); if (lyricWindow.isMinimized()) lyricWindow.restore();
lyricWindow.focus(); lyricWindow.focus();
lyricWindow.show(); lyricWindow.show();
return; return;
} }
console.log('Creating new lyric window');
createWin(); createWin();
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
lyricWindow.webContents.openDevTools({ mode: 'detach' }); lyricWindow.webContents.openDevTools({ mode: 'detach' });
@@ -39,26 +66,39 @@ const loadLyricWindow = (ipcMain) => {
} }
lyricWindow.setMinimumSize(600, 200); lyricWindow.setMinimumSize(600, 200);
// 隐藏任务栏
lyricWindow.setSkipTaskbar(true); lyricWindow.setSkipTaskbar(true);
lyricWindow.once('ready-to-show', () => {
console.log('Lyric window ready to show');
lyricWindow.show(); lyricWindow.show();
}); });
});
ipcMain.on('send-lyric', (e, data) => { ipcMain.on('send-lyric', (e, data) => {
if (lyricWindow) { if (lyricWindow && !lyricWindow.isDestroyed()) {
try {
lyricWindow.webContents.send('receive-lyric', data); 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) => { ipcMain.on('top-lyric', (e, data) => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.setAlwaysOnTop(data); lyricWindow.setAlwaysOnTop(data);
}
}); });
ipcMain.on('close-lyric', () => { ipcMain.on('close-lyric', () => {
if (lyricWindow && !lyricWindow.isDestroyed()) {
lyricWindow.webContents.send('lyric-window-close');
mainWin.webContents.send('lyric-control-back', 'close');
lyricWindow.close(); lyricWindow.close();
lyricWindow = null; lyricWindow = null;
}
}); });
ipcMain.on('mouseenter-lyric', () => { ipcMain.on('mouseenter-lyric', () => {
@@ -68,6 +108,47 @@ const loadLyricWindow = (ipcMain) => {
ipcMain.on('mouseleave-lyric', () => { ipcMain.on('mouseleave-lyric', () => {
lyricWindow.setIgnoreMouseEvents(false); 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 = { module.exports = {

View File

@@ -8,7 +8,8 @@
<!-- SEO 元数据 --> <!-- SEO 元数据 -->
<title>网抑云音乐 | AlgerKong AlgerMusicPlayer</title> <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="keywords" content="AlgerMusic, AlgerMusicPlayer, 网抑云, 音乐播放器, 在线音乐, 免费音乐, 歌词显示, 音乐下载, AlgerKong, 网易云音乐" />
<!-- 作者信息 --> <!-- 作者信息 -->
@@ -50,32 +51,7 @@
Total Visits <span id="vercount_value_site_pv">Loading</span> Total Visits <span id="vercount_value_site_pv">Loading</span>
Site Total Visitors <span id="vercount_value_site_uv">Loading</span> Site Total Visitors <span id="vercount_value_site_uv">Loading</span>
</div> </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" />
<script type="module" src="/src/main.ts"></script> <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> </body>
</html> </html>

View File

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

View File

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

View File

@@ -2,15 +2,27 @@ import { IData } from '@/type';
import { IMvItem, IMvUrlData } from '@/type/mv'; import { IMvItem, IMvUrlData } from '@/type/mv';
import request from '@/utils/request'; import request from '@/utils/request';
interface MvParams {
limit?: number;
offset?: number;
area?: string;
}
// 获取 mv 排行 // 获取 mv 排行
export const getTopMv = (limit = 30, offset = 0) => { export const getTopMv = (params: MvParams) => {
return request({ return request({
url: '/mv/all', url: '/mv/all',
method: 'get', method: 'get',
params: { params,
limit, });
offset, };
},
// 获取所有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> </template>
<div class="p-6 bg-black rounded-lg shadow-lg"> <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"> <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> <span class="text-sm text-gray-100">支付宝</span>
</div> </div>
<div class="flex flex-col items-center gap-2"> <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> <span class="text-sm text-gray-100">微信支付</span>
</div> </div>
</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> </div>
</n-popover> </n-popover>
</div> </div>
@@ -31,6 +37,12 @@
<script setup> <script setup>
import { NButton, NImage, NPopover } from 'naive-ui'; import { NButton, NImage, NPopover } from 'naive-ui';
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
message.success('已复制到剪贴板');
};
defineProps({ defineProps({
alipayQR: { alipayQR: {
type: String, type: String,

View File

@@ -1,11 +1,12 @@
<template> <template>
<n-drawer <n-drawer
:show="show" :show="show"
:height="isMobile ? '100vh' : '80vh'" :height="isMobile ? '100%' : '80%'"
placement="bottom" placement="bottom"
block-scroll block-scroll
mask-closable mask-closable
:style="{ backgroundColor: 'transparent' }" :style="{ backgroundColor: 'transparent' }"
:to="`#layout-main`"
@mask-click="close" @mask-click="close"
> >
<div class="music-page"> <div class="music-page">
@@ -24,7 +25,7 @@
<div class="music-info"> <div class="music-info">
<div class="music-cover"> <div class="music-cover">
<n-image <n-image
:src="getImgUrl(listInfo?.coverImgUrl, '300y300')" :src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '300y300')"
class="cover-img" class="cover-img"
preview-disabled preview-disabled
:class="setAnimationClass('animate__fadeIn')" :class="setAnimationClass('animate__fadeIn')"
@@ -46,9 +47,10 @@
<!-- 右侧歌曲列表 --> <!-- 右侧歌曲列表 -->
<div class="music-list-container"> <div class="music-list-container">
<div v-loading="loading" class="music-list"> <div class="music-list">
<n-scrollbar @scroll="handleScroll"> <n-scrollbar @scroll="handleScroll">
<div v-loading="loading || !songList.length" class="music-list-content"> <n-spin :show="loadingList || loading">
<div class="music-list-content">
<div <div
v-for="(item, index) in displayedSongs" v-for="(item, index) in displayedSongs"
:key="item.id" :key="item.id"
@@ -61,6 +63,7 @@
<div v-if="isLoadingMore" class="loading-more">加载更多...</div> <div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<play-bottom /> <play-bottom />
</div> </div>
</n-spin>
</n-scrollbar> </n-scrollbar>
</div> </div>
<play-bottom /> <play-bottom />
@@ -81,7 +84,8 @@ import PlayBottom from './common/PlayBottom.vue';
const store = useStore(); const store = useStore();
const props = defineProps<{ const props = withDefaults(
defineProps<{
show: boolean; show: boolean;
name: string; name: string;
songList: any[]; songList: any[];
@@ -90,13 +94,21 @@ const props = defineProps<{
trackIds: { id: number }[]; trackIds: { id: number }[];
[key: string]: any; [key: string]: any;
}; };
}>(); cover?: boolean;
}>(),
{
loading: false,
cover: true,
},
);
const emit = defineEmits(['update:show', 'update:loading']); const emit = defineEmits(['update:show', 'update:loading']);
const page = ref(0); const page = ref(0);
const pageSize = 20; const pageSize = 20;
const isLoadingMore = ref(false); const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]); const displayedSongs = ref<any[]>([]);
const loadingList = ref(false);
// 计算总数 // 计算总数
const total = computed(() => { const total = computed(() => {
@@ -165,6 +177,7 @@ const loadMoreSongs = async () => {
console.error('加载歌曲失败:', error); console.error('加载歌曲失败:', error);
} finally { } finally {
isLoadingMore.value = false; 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 变化,重置分页状态 // 监听 songList 变化,重置分页状态
watch( watch(
() => props.songList, () => props.songList,
@@ -193,6 +216,7 @@ watch(
if (newSongs.length > pageSize) { if (newSongs.length > pageSize) {
page.value = 1; page.value = 1;
} }
loadingList.value = false;
}, },
{ immediate: true }, { immediate: true },
); );
@@ -253,6 +277,10 @@ watch(
&-list { &-list {
@apply flex-grow min-h-0; @apply flex-grow min-h-0;
&-content {
@apply min-h-[calc(80vh-60px)];
}
:deep(.n-virtual-list__scroll) { :deep(.n-virtual-list__scroll) {
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {

View File

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

View File

@@ -20,7 +20,14 @@
</div> </div>
</template> </template>
</div> </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> </div>
</template> </template>
@@ -41,15 +48,28 @@ const loadAlbumList = async () => {
const showMusic = ref(false); const showMusic = ref(false);
const songList = ref([]); const songList = ref([]);
const albumName = ref(''); const albumName = ref('');
const loadingList = ref(false);
const albumInfo = ref<any>({});
const handleClick = async (item: any) => { const handleClick = async (item: any) => {
songList.value = [];
albumInfo.value = {};
albumName.value = item.name; albumName.value = item.name;
loadingList.value = true;
showMusic.value = true; showMusic.value = true;
const res = await getAlbum(item.id); const res = await getAlbum(item.id);
songList.value = res.data.songs.map((song: any) => { songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || item.picUrl; song.al.picUrl = song.al.picUrl || item.picUrl;
return song; 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(() => { onMounted(() => {

View File

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

View File

@@ -6,7 +6,7 @@
<img src="@/assets/logo.png" alt="App Icon" /> <img src="@/assets/logo.png" alt="App Icon" />
</div> </div>
<div class="app-info"> <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> <p class="app-desc mb-2">在桌面安装应用获得更好的体验</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox> <n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div> </div>
@@ -15,6 +15,15 @@
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button> <n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button> <n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
</div> </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> </div>
</n-modal> </n-modal>
</template> </template>
@@ -22,6 +31,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import config from '@/../package.json';
import { isMobile } from '@/utils';
const showModal = ref(false); const showModal = ref(false);
const isElectron = ref((window as any).electron !== undefined); const isElectron = ref((window as any).electron !== undefined);
const noPrompt = ref(false); 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(() => { onMounted(() => {
// 如果是 electron 环境,不显示安装提示 // 如果是 electron 环境,不显示安装提示
if (isElectron.value) { if (isElectron.value || isMobile.value) {
return; return;
} }
@@ -69,6 +58,29 @@ onMounted(() => {
} }
showModal.value = true; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -77,11 +89,11 @@ onMounted(() => {
@apply max-w-sm; @apply max-w-sm;
} }
.modal-content { .modal-content {
@apply p-4; @apply p-4 pb-0;
.modal-header { .modal-header {
@apply flex items-center mb-6; @apply flex items-center mb-6;
.app-icon { .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 { img {
@apply w-full h-full object-cover; @apply w-full h-full object-cover;
} }
@@ -97,7 +109,7 @@ onMounted(() => {
} }
} }
.modal-actions { .modal-actions {
@apply flex gap-3; @apply flex gap-3 mt-4;
.n-button { .n-button {
@apply flex-1; @apply flex-1;
} }

View File

@@ -17,8 +17,15 @@ export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度 export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲 export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound()); export const sound = ref<Howl | null>(audioService.getCurrentSound());
export const isLyricWindowOpen = ref(false); // 新增状态
document.onkeyup = (e) => { document.onkeyup = (e) => {
// 检查事件目标是否是输入框元素
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
switch (e.code) { switch (e.code) {
case 'Space': case 'Space':
if (store.state.play) { if (store.state.play) {
@@ -47,13 +54,18 @@ watch(
watch( watch(
() => store.state.playMusic, () => store.state.playMusic,
() => { () => {
nextTick(() => { nextTick(async () => {
lrcArray.value = playMusic.value.lyric?.lrcArray || []; lrcArray.value = playMusic.value.lyric?.lrcArray || [];
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || []; lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
if (isElectron.value && isLyricWindowOpen.value && lrcArray.value.length > 0) {
sendLyricToWin();
}
}); });
}, },
{ {
deep: true, deep: true,
immediate: true,
}, },
); );
@@ -70,8 +82,13 @@ export const audioServiceOn = (audio: typeof audioService) => {
if (newIndex !== nowIndex.value) { if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex; nowIndex.value = newIndex;
currentLrcProgress.value = 0; currentLrcProgress.value = 0;
// 当歌词索引更新时,发送歌词数据
if (isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin();
} }
if (isElectron.value) { }
// 定期发送歌词数据更新
if (isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin(); sendLyricToWin();
} }
}, 50); }, 50);
@@ -81,12 +98,21 @@ export const audioServiceOn = (audio: typeof audioService) => {
audio.onPause(() => { audio.onPause(() => {
store.commit('setPlayMusic', false); store.commit('setPlayMusic', false);
clearInterval(interval); clearInterval(interval);
// 暂停时也发送一次状态更新
if (isElectron.value && isLyricWindowOpen.value) {
sendLyricToWin();
}
}); });
// 监听结束 // 监听结束
audio.onEnd(() => { audio.onEnd(() => {
handleEnded(); if (store.state.playMode === 1) {
// 单曲循环模式
audio.getCurrentSound()?.play();
} else {
// 列表循环模式
store.commit('nextPlay'); store.commit('nextPlay');
}
}); });
}; };
@@ -201,7 +227,7 @@ export const useLyricProgress = () => {
}; };
}; };
// 设置前播放时间 // 设置<EFBFBD><EFBFBD><EFBFBD>前播放时间
export const setAudioTime = (index: number) => { export const setAudioTime = (index: number) => {
const currentSound = sound.value; const currentSound = sound.value;
if (!currentSound) return; if (!currentSound) return;
@@ -229,72 +255,33 @@ export const getLrcTimeRange = (index: number) => ({
watch( watch(
() => lrcArray.value, () => lrcArray.value,
(newLrcArray) => { (newLrcArray) => {
if (newLrcArray.length > 0 && isElectron.value) { if (newLrcArray.length > 0 && isElectron.value && isLyricWindowOpen.value) {
// 重新初始化歌词数据
initLyricWindow();
// 发送当前状态
sendLyricToWin(); 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) => { export const sendLyricToWin = () => {
if (!isElectron.value) return; if (!isElectron.value || !isLyricWindowOpen.value) {
console.log('Cannot send lyric: electron or lyric window not available');
return;
}
try { try {
if (lrcArray.value.length > 0) { if (lrcArray.value.length > 0) {
const nowIndex = getLrcIndex(nowTime.value); const nowIndex = getLrcIndex(nowTime.value);
const updateData = { const updateData = {
type: 'update', type: 'full',
nowIndex, nowIndex,
nowTime: nowTime.value, nowTime: nowTime.value,
startCurrentTime: lrcTimeArray.value[nowIndex], startCurrentTime: lrcTimeArray.value[nowIndex],
nextTime: lrcTimeArray.value[nowIndex + 1], 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)); windowData.electronAPI.sendLyric(JSON.stringify(updateData));
} }
@@ -305,13 +292,52 @@ export const sendLyricToWin = (isPlay: boolean = true) => {
export const openLyric = () => { export const openLyric = () => {
if (!isElectron.value) return; if (!isElectron.value) return;
console.log('Opening lyric window'); console.log('Opening lyric window with current song:', playMusic.value?.name);
windowData.electronAPI.openLyric();
// 延迟一下初始化,确保窗口已经创建 isLyricWindowOpen.value = !isLyricWindowOpen.value;
if (isLyricWindowOpen.value) {
setTimeout(() => { setTimeout(() => {
console.log('Initializing lyric window after delay'); windowData.electronAPI.openLyric();
initLyricWindow();
sendLyricToWin(); sendLyricToWin();
}, 500); }, 500);
sendLyricToWin();
} else {
closeLyric();
}
}; };
// 添加关闭歌词窗口的方法
export const closeLyric = () => {
if (!isElectron.value) return;
windowData.electron.ipcRenderer.send('close-lyric');
};
// 添加播放控制命令监听
if (isElectron.value) {
windowData.electron.ipcRenderer.on('lyric-control-back', (command: string) => {
console.log('Received playback control command:', command);
switch (command) {
case 'playpause':
if (store.state.play) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
break;
case 'prev':
store.commit('prevPlay');
break;
case 'next':
store.commit('nextPlay');
break;
case 'close':
closeLyric();
break;
default:
console.log('Unknown command:', command);
break;
}
});
}

View File

@@ -10,6 +10,10 @@
width: 100%; 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 { .text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap; @apply overflow-ellipsis overflow-hidden whitespace-nowrap;
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="layout-page"> <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" /> <title-bar v-if="isElectron" />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'"> <div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
<!-- 侧边菜单栏 --> <!-- 侧边菜单栏 -->
@@ -10,7 +10,6 @@
<search-bar /> <search-bar />
<!-- 主页面路由 --> <!-- 主页面路由 -->
<div class="main-content" :native-scrollbar="false"> <div class="main-content" :native-scrollbar="false">
<n-message-provider>
<router-view <router-view
v-slot="{ Component }" v-slot="{ Component }"
class="main-page" class="main-page"
@@ -20,7 +19,6 @@
<component :is="Component" /> <component :is="Component" />
</keep-alive> </keep-alive>
</router-view> </router-view>
</n-message-provider>
</div> </div>
<play-bottom height="5rem" /> <play-bottom height="5rem" />
<app-menu v-if="isMobile" class="menu" :menus="menus" /> <app-menu v-if="isMobile" class="menu" :menus="menus" />

View File

@@ -1,9 +1,10 @@
<template> <template>
<n-drawer <n-drawer
:show="musicFull" :show="musicFull"
height="100vh" height="100%"
placement="bottom" placement="bottom"
:style="{ background: currentBackground || background }" :style="{ background: currentBackground || background }"
:to="`#layout-main`"
> >
<div id="drawer-target"> <div id="drawer-target">
<div class="drawer-back"></div> <div class="drawer-back"></div>

View File

@@ -2,10 +2,14 @@
<!-- 展开全屏 --> <!-- 展开全屏 -->
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" /> <music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
<!-- 底部播放栏 --> <!-- 底部播放栏 -->
<div <div
class="music-play-bar" class="music-play-bar"
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')" :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"> <div class="play-bar-img-wrapper" @click="setMusicFull">
<n-image :src="getImgUrl(playMusic?.picUrl, '300y300')" class="play-bar-img" lazy preview-disabled /> <n-image :src="getImgUrl(playMusic?.picUrl, '300y300')" class="play-bar-img" lazy preview-disabled />
<div class="hover-arrow"> <div class="hover-arrow">
@@ -38,37 +42,38 @@
<div class="music-buttons-play" @click="playMusicEvent"> <div class="music-buttons-play" @click="playMusicEvent">
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i> <i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
</div> </div>
<div class="music-buttons-next" @click="handleEnded"> <div class="music-buttons-next" @click="handleNext">
<i class="iconfont icon-next"></i> <i class="iconfont icon-next"></i>
</div> </div>
</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"> <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> <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> </template>
喜欢 喜欢
</n-tooltip> --> </n-tooltip>
<!-- <n-tooltip trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-Play" @click="parsingMusic"></i>
</template>
解析播放
</n-tooltip> -->
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999"> <n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
<template #trigger> <template #trigger>
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i> <i
class="iconfont ri-netease-cloud-music-line"
:class="{ 'text-green-500': isLyricWindowOpen }"
@click="openLyricWindow"
></i>
</template> </template>
歌词 歌词
</n-tooltip> </n-tooltip>
@@ -112,7 +117,7 @@ import { useTemplateRef } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue'; import SongItem from '@/components/common/SongItem.vue';
import { allTime, isElectron, nowTime, openLyric, sound } from '@/hooks/MusicHook'; import { allTime, isElectron, isLyricWindowOpen, nowTime, openLyric, sound } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music'; import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils'; import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
@@ -139,17 +144,33 @@ watch(
// 使用 useThrottleFn 创建节流版本的 seek 函数 // 使用 useThrottleFn 创建节流版本的 seek 函数
const throttledSeek = useThrottleFn((value: number) => { const throttledSeek = useThrottleFn((value: number) => {
if (!sound.value) return; if (!sound.value) return;
sound.value.seek((value * allTime.value) / 100); sound.value.seek(value);
nowTime.value = value;
}, 50); // 50ms 的节流延迟 }, 50); // 50ms 的节流延迟
// 修改 timeSlider 计算属性 // 修改 timeSlider 计算属性
const timeSlider = computed({ const timeSlider = computed({
get: () => (nowTime.value / allTime.value) * 100, get: () => nowTime.value,
set: throttledSeek, 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 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({ const volumeSlider = computed({
get: () => audioVolume.value * 100, get: () => audioVolume.value * 100,
set: (value) => { set: (value) => {
@@ -159,17 +180,31 @@ const volumeSlider = computed({
audioVolume.value = value / 100; 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(() => { const togglePlayMode = () => {
return secondToMinute(allTime.value); store.commit('togglePlayMode');
}); };
function handleEnded() { function handleNext() {
store.commit('nextPlay'); store.commit('nextPlay');
} }
@@ -209,6 +244,23 @@ const scrollToPlayList = (val: boolean) => {
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 }); palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 });
}, 50); }, 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -217,13 +269,13 @@ const scrollToPlayList = (val: boolean) => {
} }
.music-play-bar { .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; z-index: 9999;
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034); box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
background-color: #212121; background-color: #212121;
animation-duration: 0.5s !important; animation-duration: 0.5s !important;
.music-content { .music-content {
width: 140px; width: 160px;
@apply ml-4; @apply ml-4;
&-title { &-title {
@@ -246,14 +298,14 @@ const scrollToPlayList = (val: boolean) => {
} }
.music-buttons { .music-buttons {
@apply mx-6; @apply mx-6 flex-1 flex justify-center;
.iconfont { .iconfont {
@apply text-2xl hover:text-green-500 transition; @apply text-2xl hover:text-green-500 transition;
} }
.icon { .icon {
@apply text-xl hover:text-white; @apply text-3xl hover:text-white;
} }
@apply flex items-center; @apply flex items-center;
@@ -263,25 +315,28 @@ const scrollToPlayList = (val: boolean) => {
} }
&-play { &-play {
background: #383838; background-color: #ffffff20;
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition bg-opacity-40; @apply flex justify-center items-center w-20 h-12 rounded-full mx-4 hover:bg-[#ffffff40] transition;
}
}
.music-time {
@apply flex flex-1 items-center;
.time {
@apply mx-4 mt-1;
} }
} }
.audio-volume { .audio-volume {
width: 140px; @apply flex items-center relative;
@apply flex items-center mx-4; &:hover {
.volume-slider {
@apply opacity-100 visible;
}
}
.volume-icon {
@apply cursor-pointer;
.iconfont { .iconfont {
@apply text-2xl hover:text-green-500 transition cursor-pointer mr-4; @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-size: 12px;
--n-handle-color: var(--primary-color); --n-handle-color: var(--primary-color);
&:hover { &.n-slider--vertical {
--n-rail-height: 6px; height: 100%;
--n-handle-size: 14px;
}
.n-slider-rail { .n-slider-rail {
@apply overflow-hidden; width: 4px;
}
&:hover {
.n-slider-rail {
width: 6px;
} }
.n-slider-handle { .n-slider-handle {
@apply transition-opacity duration-200; width: 14px;
height: 14px;
}
}
}
.n-slider-rail {
@apply overflow-hidden transition-all duration-200;
}
.n-slider-handle {
@apply transition-all duration-200;
opacity: 0; opacity: 0;
} }
@@ -407,4 +476,21 @@ const scrollToPlayList = (val: boolean) => {
.play-bar-img { .play-bar-img {
@apply w-14 h-14 rounded-2xl; @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> </style>

View File

@@ -13,32 +13,53 @@
<i class="iconfont icon-search"></i> <i class="iconfont icon-search"></i>
</template> </template>
<template #suffix> <template #suffix>
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<div class="w-20 px-3 flex justify-between items-center"> <div class="w-20 px-3 flex justify-between items-center">
<div>{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}</div> <div>{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}</div>
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<i class="iconfont icon-xiasanjiaoxing"></i> <i class="iconfont icon-xiasanjiaoxing"></i>
</n-dropdown>
</div> </div>
</n-dropdown>
</template> </template>
</n-input> </n-input>
</div> </div>
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
<template #trigger>
<div class="user-box"> <div class="user-box">
<n-dropdown trigger="hover" :options="userSetOptions" @select="selectItem">
<i class="iconfont icon-xiasanjiaoxing"></i>
</n-dropdown>
<n-avatar <n-avatar
v-if="store.state.user" v-if="store.state.user"
class="ml-2 cursor-pointer" class="ml-2 cursor-pointer"
circle circle
size="medium" size="medium"
:src="getImgUrl(store.state.user.avatarUrl)" :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 v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
</div> </div>
<coffee </template>
alipay-q-r="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" <div class="user-popover">
wechat-q-r="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" <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"> <div class="github" @click="toGithub">
<i class="ri-github-fill"></i> <i class="ri-github-fill"></i>
</div> </div>
@@ -50,8 +71,11 @@
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import config from '@/../package.json';
import { getSearchKeyword } from '@/api/home'; import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login'; 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 Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const'; import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { getImgUrl } from '@/utils'; import { getImgUrl } from '@/utils';
@@ -141,6 +165,9 @@ const selectItem = async (key: string) => {
case 'set': case 'set':
router.push('/set'); router.push('/set');
break; break;
case 'user':
router.push('/user');
break;
default: default:
} }
}; };
@@ -148,11 +175,15 @@ const selectItem = async (key: string) => {
const toGithub = () => { const toGithub = () => {
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank'); window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank');
}; };
const toGithubRelease = () => {
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.user-box { .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; background: #1a1a1a;
} }
.search-box { .search-box {
@@ -171,4 +202,65 @@ const toGithub = () => {
.github { .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; @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> </style>

View File

@@ -15,14 +15,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { useDialog } from 'naive-ui'; import { useDialog } from 'naive-ui';
import { isElectron } from '@/hooks/MusicHook';
const dialog = useDialog(); const dialog = useDialog();
const windowData = window as any; const windowData = window as any;
const minimize = () => { const minimize = () => {
if (!isElectron.value) {
return;
}
windowData.electronAPI.minimize(); windowData.electronAPI.minimize();
}; };
const close = () => { const close = () => {
if (!isElectron.value) {
return;
}
dialog.warning({ dialog.warning({
title: '提示', title: '提示',
content: '确定要退出吗?', content: '确定要退出吗?',
@@ -38,6 +46,9 @@ const close = () => {
}; };
const drag = (event: MouseEvent) => { const drag = (event: MouseEvent) => {
if (!isElectron.value) {
return;
}
windowData.electronAPI.dragStart(event); windowData.electronAPI.dragStart(event);
}; };
</script> </script>

View File

@@ -6,6 +6,7 @@ const layoutRouter = [
title: '首页', title: '首页',
icon: 'icon-Home', icon: 'icon-Home',
keepAlive: true, keepAlive: true,
isMobile: true,
}, },
component: () => import('@/views/home/index.vue'), component: () => import('@/views/home/index.vue'),
}, },
@@ -17,6 +18,7 @@ const layoutRouter = [
noScroll: true, noScroll: true,
icon: 'icon-Search', icon: 'icon-Search',
keepAlive: true, keepAlive: true,
isMobile: true,
}, },
component: () => import('@/views/search/index.vue'), component: () => import('@/views/search/index.vue'),
}, },
@@ -27,6 +29,7 @@ const layoutRouter = [
title: '歌单', title: '歌单',
icon: 'icon-Paper', icon: 'icon-Paper',
keepAlive: true, keepAlive: true,
isMobile: true,
}, },
component: () => import('@/views/list/index.vue'), component: () => import('@/views/list/index.vue'),
}, },
@@ -37,27 +40,29 @@ const layoutRouter = [
title: 'MV', title: 'MV',
icon: 'icon-recordfill', icon: 'icon-recordfill',
keepAlive: true, keepAlive: true,
isMobile: true,
}, },
component: () => import('@/views/mv/index.vue'), 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', path: '/history',
name: 'history', name: 'history',
component: () => import('@/views/historyAndFavorite/index.vue'),
meta: { meta: {
title: '历史', title: '我的收藏和历史',
icon: 'icon-a-TicketStar', icon: 'icon-a-TicketStar',
keepAlive: true, 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', path: '/user',
@@ -67,8 +72,20 @@ const layoutRouter = [
icon: 'icon-Profile', icon: 'icon-Profile',
keepAlive: true, keepAlive: true,
noScroll: true, noScroll: true,
isMobile: true,
}, },
component: () => import('@/views/user/index.vue'), 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; export default layoutRouter;

View File

@@ -13,6 +13,11 @@ const defaultSettings = {
authorUrl: 'https://github.com/algerkong', 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 { interface State {
menus: any[]; menus: any[];
play: boolean; play: boolean;
@@ -28,6 +33,7 @@ interface State {
searchValue: string; searchValue: string;
searchType: number; searchType: number;
favoriteList: number[]; favoriteList: number[];
playMode: number;
} }
const state: State = { const state: State = {
@@ -36,7 +42,7 @@ const state: State = {
isPlay: false, isPlay: false,
playMusic: {} as SongResult, playMusic: {} as SongResult,
playMusicUrl: '', playMusicUrl: '',
user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null, user: getLocalStorageItem('user', null),
playList: [], playList: [],
playListIndex: 0, playListIndex: 0,
setData: defaultSettings, setData: defaultSettings,
@@ -44,7 +50,8 @@ const state: State = {
isMobile: false, isMobile: false,
searchValue: '', searchValue: '',
searchType: 1, searchType: 1,
favoriteList: localStorage.getItem('favoriteList') ? JSON.parse(localStorage.getItem('favoriteList') || '[]') : [], favoriteList: getLocalStorageItem('favoriteList', []),
playMode: getLocalStorageItem('playMode', 0),
}; };
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook(); const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
@@ -91,6 +98,10 @@ const mutations = {
state.favoriteList = state.favoriteList.filter((id) => id !== songId); state.favoriteList = state.favoriteList.filter((id) => id !== songId);
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList)); 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 = { const actions = {

View File

@@ -15,12 +15,12 @@ interface SortCategories {
interface SortAll { interface SortAll {
name: string; name: string;
resourceCount: number; resourceCount?: number;
imgId: number; imgId?: number;
imgUrl?: any; imgUrl?: any;
type: number; type?: number;
category: number; category?: number;
resourceType: number; resourceType?: number;
hot: boolean; hot?: boolean;
activity: boolean; activity?: boolean;
} }

View File

@@ -1,45 +1,35 @@
<template> <template>
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page"> <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> <h2>我的收藏</h2>
<div class="favorite-count"> {{ favoriteList.length }} </div> <div class="favorite-count"> {{ favoriteList.length }} </div>
</div> </div>
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')"> <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"> <div v-if="favoriteList.length === 0" class="empty-tip">
<n-empty description="还没有收藏歌曲" /> <n-empty description="还没有收藏歌曲" />
</div> </div>
<div v-else class="favorite-list"> <div v-else class="favorite-list">
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<template v-else>
<song-item <song-item
v-for="(song, index) in favoriteSongs" v-for="(song, index) in favoriteSongs"
:key="song.id" :key="song.id"
:item="song" :item="song"
:favorite="!isComponent" :favorite="!isComponent"
:class="setAnimationClass('animate__bounceInUp')" :class="setAnimationClass('animate__bounceInLeft')"
:style="getItemAnimationDelay(index)" :style="getItemAnimationDelay(index)"
@play="handlePlay" @play="handlePlay"
/> />
</template>
<div v-if="isComponent" class="favorite-list-more text-center"> <div v-if="isComponent" class="favorite-list-more text-center">
<n-button text type="primary" @click="handleMore">查看更多</n-button> <n-button text type="primary" @click="handleMore">查看更多</n-button>
</div> </div>
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">没有更多了</div>
</div> </div>
</n-scrollbar> </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>
</div> </div>
</template> </template>
@@ -58,12 +48,14 @@ const store = useStore();
const favoriteList = computed(() => store.state.favoriteList); const favoriteList = computed(() => store.state.favoriteList);
const favoriteSongs = ref<SongResult[]>([]); const favoriteSongs = ref<SongResult[]>([]);
const loading = ref(false); const loading = ref(false);
const noMore = ref(false);
const scrollbarRef = ref();
// 分页相关 // 无限滚动相关
const pageSize = 16; const pageSize = 16;
const currentPage = ref(1); const currentPage = ref(1);
defineProps({ const props = defineProps({
isComponent: { isComponent: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -72,7 +64,6 @@ defineProps({
// 获取当前页的收藏歌曲ID // 获取当前页的收藏歌曲ID
const getCurrentPageIds = () => { const getCurrentPageIds = () => {
// 反转列表顺序,最新收藏的在前面
const reversedList = [...favoriteList.value]; const reversedList = [...favoriteList.value];
const startIndex = (currentPage.value - 1) * pageSize; const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize; const endIndex = startIndex + pageSize;
@@ -86,17 +77,29 @@ const getFavoriteSongs = async () => {
return; return;
} }
if (props.isComponent && favoriteSongs.value.length >= 16) {
return;
}
loading.value = true; loading.value = true;
try { try {
const currentIds = getCurrentPageIds(); const currentIds = getCurrentPageIds();
const res = await getMusicDetail(currentIds); const res = await getMusicDetail(currentIds);
if (res.data.songs) { if (res.data.songs) {
favoriteSongs.value = res.data.songs.map((song: SongResult) => { const newSongs = res.data.songs.map((song: SongResult) => ({
return {
...song, ...song,
picUrl: song.al?.picUrl || '', 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) { } catch (error) {
console.error('获取收藏歌曲失败:', error); console.error('获取收藏歌曲失败:', error);
@@ -105,9 +108,15 @@ const getFavoriteSongs = async () => {
} }
}; };
// 处理页码变化 // 处理滚动事件
const handlePageChange = () => { 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(); getFavoriteSongs();
}
}; };
onMounted(() => { onMounted(() => {
@@ -119,6 +128,7 @@ watch(
favoriteList, favoriteList,
() => { () => {
currentPage.value = 1; currentPage.value = 1;
noMore.value = false;
getFavoriteSongs(); getFavoriteSongs();
}, },
{ deep: true, immediate: true }, { deep: true, immediate: true },
@@ -129,8 +139,7 @@ const handlePlay = () => {
}; };
const getItemAnimationDelay = (index: number) => { const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize; return setAnimationDelay(index, 30);
return setAnimationDelay(currentPageIndex, 30);
}; };
const router = useRouter(); const router = useRouter();
@@ -141,13 +150,13 @@ const handleMore = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.favorite-page { .favorite-page {
@apply h-full flex flex-col p-6; @apply h-full flex flex-col pt-2;
.favorite-header { .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 { h2 {
@apply text-2xl font-bold; @apply text-xl font-bold pb-2;
} }
.favorite-count { .favorite-count {
@@ -166,7 +175,7 @@ const handleMore = () => {
} }
.favorite-list { .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; @apply flex justify-center items-center py-20;
} }
.pagination-wrapper { .no-more-tip {
@apply flex justify-center py-4 flex-shrink-0; @apply text-center text-gray-400 py-4 text-sm;
: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;
}
}
}
} }
.mobile { .mobile {

View File

@@ -1,32 +1,41 @@
<template> <template>
<div class="history-page"> <div class="history-page">
<div class="title">播放历史</div> <div class="title" :class="setAnimationClass('animate__fadeInRight')">播放历史</div>
<n-scrollbar :size="100"> <n-scrollbar ref="scrollbarRef" :size="100" @scroll="handleScroll">
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')"> <div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div <div
v-for="(item, index) in musicList" v-for="(item, index) in displayList"
:key="item.id" :key="item.id"
class="history-item" class="history-item"
:class="setAnimationClass('animate__bounceIn')" :class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 30)" :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]"> <div class="history-item-count min-w-[60px]">
{{ item.count }} {{ item.count }}
</div> </div>
<div class="history-item-delete"> <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> </div>
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">没有更多了</div>
</div> </div>
</n-scrollbar> </n-scrollbar>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook'; import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils'; import { setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({ defineOptions({
@@ -35,9 +44,81 @@ defineOptions({
const store = useStore(); const store = useStore();
const { delMusic, musicList } = useMusicHistory(); 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 = () => { 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> </script>
@@ -45,11 +126,11 @@ const handlePlay = () => {
.history-page { .history-page {
@apply h-full w-full pt-2; @apply h-full w-full pt-2;
.title { .title {
@apply pl-4 text-xl font-bold; @apply pl-4 text-xl font-bold pb-2 px-4;
} }
.history-list-content { .history-list-content {
@apply px-4 mt-2 pb-28; @apply mt-2 pb-28 px-4;
.history-item { .history-item {
@apply flex items-center justify-between; @apply flex items-center justify-between;
&-content { &-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> </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) { :deep(.favorite-page) {
@apply p-0 mx-4 h-[300px]; @apply p-0 mx-4 h-[300px];
.favorite-header { .favorite-header {
@apply mb-0; @apply mb-0 px-0 !important;
h2 { h2 {
@apply text-lg font-bold mb-4; @apply text-lg font-bold;
} }
} }
.favorite-list {
@apply px-0 !important;
}
} }
</style> </style>

View File

@@ -1,10 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useRoute } from 'vue-router'; 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 MusicList from '@/components/MusicList.vue';
import type { IRecommendItem } from '@/type/list'; import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail'; import type { IListDetail } from '@/type/listDetail';
import type { IPlayListSort } from '@/type/playlist';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils'; import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({ defineOptions({
@@ -85,14 +87,40 @@ const handleScroll = (e: any) => {
} }
}; };
onMounted(() => { // 添加歌单分类相关的代码
if (route.query.type) { const playlistCategory = ref<IPlayListSort>();
loadList(route.query.type as string); const currentType = ref((route.query.type as string) || '每日推荐');
} else {
getRecommendList(TOTAL_ITEMS).then((res: { data: { result: any } }) => { const getAnimationDelay = computed(() => {
recommendList.value = res.data.result; return (index: number) => setAnimationDelay(index, 30);
}); });
}
// 加载歌单分类
const loadPlaylistCategory = async () => {
const { data } = await getPlaylistCategory();
playlistCategory.value = {
...data,
sub: [
{
name: '每日推荐',
category: 0,
},
...data.sub,
],
};
};
const handleClickPlaylistType = (type: string) => {
currentType.value = type;
listTitle.value = type;
loading.value = true;
loadList(type);
};
onMounted(() => {
loadPlaylistCategory(); // 添加加载歌单分类
currentType.value = (route.query.type as string) || currentType.value;
loadList(currentType.value);
}); });
watch( watch(
@@ -101,6 +129,8 @@ watch(
if (newParams.type) { if (newParams.type) {
recommendList.value = []; recommendList.value = [];
listTitle.value = newParams.type || '歌单列表'; listTitle.value = newParams.type || '歌单列表';
currentType.value = newParams.type as string;
loading.value = true;
loadList(newParams.type as string); loadList(newParams.type as string);
} }
}, },
@@ -109,7 +139,23 @@ watch(
<template> <template>
<div class="list-page"> <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"> <n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
<div v-loading="loading" class="recommend-list"> <div v-loading="loading" class="recommend-list">
@@ -228,4 +274,35 @@ watch(
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); 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> </style>

View File

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

View File

@@ -3,6 +3,22 @@
<div class="mv-list-title"> <div class="mv-list-title">
<h2>推荐MV</h2> <h2>推荐MV</h2>
</div> </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"> <n-scrollbar :size="100" @scroll="handleScroll">
<div v-loading="initLoading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')"> <div v-loading="initLoading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div <div
@@ -10,7 +26,7 @@
:key="item.id" :key="item.id"
class="mv-item" class="mv-item"
:class="setAnimationClass('animate__bounceIn')" :class="setAnimationClass('animate__bounceIn')"
:style="getItemAnimationDelay(index)" :style="getAnimationDelay(index)"
> >
<div class="mv-item-img" @click="handleShowMv(item, 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 /> <n-image class="mv-item-img-img" :src="getImgUrl(item.cover, '320y180')" lazy preview-disabled />
@@ -38,10 +54,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { getTopMv } from '@/api/mv'; import { getAllMv, getTopMv } from '@/api/mv';
import MvPlayer from '@/components/MvPlayer.vue'; import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService'; import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv'; import { IMvItem } from '@/type/mv';
@@ -62,10 +78,26 @@ const offset = ref(0);
const limit = ref(42); const limit = ref(42);
const hasMore = ref(true); const hasMore = ref(true);
const getItemAnimationDelay = (index: number) => { const categories = [
const currentPageIndex = index % limit.value; { label: '全部', value: '全部' },
return setAnimationDelay(currentPageIndex, 30); { 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 () => { onMounted(async () => {
await loadMvList(); await loadMvList();
@@ -116,26 +148,26 @@ const playNextMv = async (setLoading: (value: boolean) => void) => {
}; };
const loadMvList = async () => { const loadMvList = async () => {
try {
if (!hasMore.value || loadingMore.value) return; if (!hasMore.value || loadingMore.value) return;
if (offset.value === 0) { if (offset.value === 0) {
initLoading.value = true; initLoading.value = true;
} else { } else {
loadingMore.value = true; loadingMore.value = true;
} }
try { const params = {
const res = await getTopMv(limit.value, offset.value); limit: limit.value,
if (offset.value === 0) { offset: offset.value,
mvList.value = res.data.data; area: selectedCategory.value === '全部' ? '' : selectedCategory.value,
} else { };
mvList.value.push(...res.data.data);
}
hasMore.value = res.data.data.length === limit.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; offset.value += limit.value;
} catch (error) {
console.error('加载MV失败:', error);
} finally { } finally {
initLoading.value = false; initLoading.value = false;
loadingMore.value = false; loadingMore.value = false;
@@ -157,12 +189,37 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
<style scoped lang="scss"> <style scoped lang="scss">
.mv-list { .mv-list {
@apply relative h-full w-full; @apply h-full flex-1 flex flex-col overflow-hidden;
&-title { &-title {
@apply text-xl font-bold pb-2; @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 { &-content {
@apply grid gap-4 pb-28 mt-2 pr-4; @apply grid gap-4 pb-28 mt-2 pr-4;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));

View File

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

View File

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