Compare commits

..

18 Commits
2.2.1 ... 2.4.0

Author SHA1 Message Date
alger
7365daf700 🐞 fix: 修复播放暂停控制问题 后续优化为参数监听 2024-12-12 22:36:07 +08:00
alger
cebf313075 feat: 优化播放 修改为howler 修复搜索导致播放无限卡顿问题(#15)
- 优化了整个项目的播放
- 去除audio
- 优化歌词页 歌词同步时间

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

View File

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

View File

@@ -7,6 +7,8 @@
- 播放历史
- 桌面歌词
- 歌单 mv 搜索 专辑等功能
- 识别无法播放歌曲 并代理播放
- 可听周杰伦(搜索专辑)
## 项目简介
一个基于 electron typescript vue3 的桌面音乐播放器 适配 web端 桌面端 web移动端
@@ -14,17 +16,10 @@
## 预览地址
[http://mc.alger.fun/](http://mc.alger.fun/)
## Stargazers over time
[![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer)
## 软件截图
![首页](./docs/img/image-7.png)
![歌词](./docs/img/image-6.png)
![歌单](./docs/img/image-1.png)
![搜索](./docs/img/image-8.png)
![mv](./docs/img/image-3.png)
![历史](./docs/img/image-4.png)
![我的](./docs/img/image-5.png)
## 技术栈
@@ -44,6 +39,11 @@
- 多平台支持Web、Desktop、Mobile Web
- 构建优化(代码分割、压缩)
## 咖啡☕️
| 微信 | 支付宝 |
| :--------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------: |
| <img src="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" alt="WeChat QRcode" width=200> | <img src="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" alt="Wechat QRcode" width=200> |
## 项目运行
```bash
# 安装依赖
@@ -68,28 +68,32 @@
```bash
# .env.development
# 你的接口地址 (必填)
VITE_API = ***
# 音乐破解接口地址
VITE_API_MUSIC = ***
# 代理地址
VITE_API_PROXY = ***
# 本地运行代理地址
VITE_API_PROXY = /api
VITE_API_LOCAL = /api
VITE_API_MUSIC_PROXY = /music
VITE_API_PROXY_MUSIC = /music_proxy
# 你的接口地址 (必填)
VITE_API = ***
# 音乐po接口地址
VITE_API_MUSIC = ***
VITE_API_PROXY = ***
# .env.production
# 你的接口地址 (必填)
VITE_API = ***
# 音乐破解接口地址
# 音乐po接口地址
VITE_API_MUSIC = ***
# 代理地址
VITE_API_PROXY = ***
```
## Stargazers over time
[![Stargazers over time](https://starchart.cc/algerkong/AlgerMusicPlayer.svg?variant=adaptive)](https://starchart.cc/algerkong/AlgerMusicPlayer)
## 欢迎提Issues
## 免责声明

4
components.d.ts vendored
View File

@@ -7,6 +7,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
Coffee: typeof import('./src/components/Coffee.vue')['default']
InstallAppModal: typeof import('./src/components/common/InstallAppModal.vue')['default']
MPop: typeof import('./src/components/common/MPop.vue')['default']
MusicList: typeof import('./src/components/MusicList.vue')['default']
@@ -14,16 +15,19 @@ declare module 'vue' {
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']

View File

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

View File

@@ -1,15 +1,40 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>网抑云 | algerkong</title>
<link rel="manifest" href="./public/manifest.json" />
<link rel="stylesheet" href="./public/icon/iconfont.css" />
<link rel="stylesheet" href="./public/css/animate.css" />
<link rel="stylesheet" href="./public/css/base.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- SEO 元数据 -->
<title>网抑云音乐 | AlgerKong AlgerMusicPlayer</title>
<meta name="description" content="AlgerMusicPlayer 网抑云音乐 基于 网易云音乐API 的一款免费的在线音乐播放器,支持在线播放、歌词显示、音乐下载等功能。提供海量音乐资源,让您随时随地享受音乐。" />
<meta name="keywords" content="AlgerMusic, AlgerMusicPlayer, 网抑云, 音乐播放器, 在线音乐, 免费音乐, 歌词显示, 音乐下载, AlgerKong, 网易云音乐" />
<!-- 作者信息 -->
<meta name="author" content="AlgerKong" />
<meta name="author-url" content="https://github.com/algerkong" />
<!-- PWA 相关 -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="网抑云音乐" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- 资源预加载 -->
<link rel="preload" href="/icon/iconfont.css" as="style" />
<link rel="preload" href="/css/animate.css" as="style" />
<link rel="preload" href="/css/base.css" as="style" />
<!-- 样式表 -->
<link rel="stylesheet" href="/icon/iconfont.css" />
<link rel="stylesheet" href="/css/animate.css" />
<link rel="stylesheet" href="/css/base.css" />
<script defer src="https://cn.vercount.one/js"></script>
<!-- 动画配置 -->
<style>
:root {
--animate-delay: 0.5s;
@@ -19,7 +44,38 @@
<body>
<div id="app"></div>
<div style="display: none;">
Total Page View <span id="vercount_value_page_pv">Loading</span>
Total Visits <span id="vercount_value_site_pv">Loading</span>
Site Total Visitors <span id="vercount_value_site_uv">Loading</span>
</div>
<!-- 收款码图片预加载 -->
<link rel="preload" as="image" href="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true" />
<link rel="preload" as="image" href="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true" />
<script type="module" src="/src/main.ts"></script>
<!-- 结构化数据 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "网抑云音乐",
"applicationCategory": "MultimediaApplication",
"operatingSystem": "Web, Windows, MacOS",
"author": {
"@type": "Person",
"name": "AlgerKong",
"url": "https://github.com/algerkong"
},
"description": "一款免费的在线音乐播放器,支持在线播放、歌词显示、音乐下载等功能。",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "CNY"
}
}
</script>
</body>
</html>

View File

@@ -17,7 +17,9 @@
"b:win": "cross-env NODE_ENV=production npm run build && npm run b:win:x64 && npm run b:win:x86 && npm run b:win:arm"
},
"dependencies": {
"electron-store": "^8.1.0"
"@types/howler": "^2.2.12",
"electron-store": "^8.1.0",
"howler": "^2.2.4"
},
"devDependencies": {
"@tailwindcss/postcss7-compat": "^2.2.4",
@@ -47,7 +49,7 @@
"postcss": "^8.4.49",
"prettier": "^3.3.3",
"remixicon": "^4.2.0",
"sass": "^1.78.0",
"sass": "^1.82.0",
"tailwindcss": "^3.4.15",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.2",

View File

@@ -1,6 +1,5 @@
<template>
<div class="app-container" :class="{ mobile: isMobile }">
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
<n-config-provider :theme="darkTheme">
<n-dialog-provider>
<router-view></router-view>
@@ -11,20 +10,14 @@
<script setup lang="ts">
import { darkTheme } from 'naive-ui';
import { onMounted } from 'vue';
import store from '@/store';
import { isMobile } from './utils';
const playMusicUrl = computed(() => store.state.playMusicUrl as string);
// 是否播放
const play = computed(() => store.state.play as boolean);
const windowData = window as any;
onMounted(() => {
if (windowData.electron) {
const setData = windowData.electron.ipcRenderer.getStoreValue('set');
store.commit('setSetData', setData);
}
store.dispatch('initializeSettings');
});
</script>

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

@@ -0,0 +1,44 @@
<template>
<div class="relative inline-block">
<n-popover trigger="hover" placement="top" :show-arrow="true" :raw="true" :delay="100">
<template #trigger>
<slot>
<n-button
quaternary
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
>
请我喝咖啡
</n-button>
</slot>
</template>
<div class="p-6 bg-black rounded-lg shadow-lg">
<div class="flex gap-6">
<div class="flex flex-col items-center gap-2">
<n-image :src="alipayQR" alt="支付宝收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<span class="text-sm text-gray-100">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image :src="wechatQR" alt="微信收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<span class="text-sm text-gray-100">微信支付</span>
</div>
</div>
</div>
</n-popover>
</div>
</template>
<script setup>
import { NButton, NImage, NPopover } from 'naive-ui';
defineProps({
alipayQR: {
type: String,
required: true,
},
wechatQR: {
type: String,
required: true,
},
});
</script>

View File

@@ -1,7 +1,7 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100vh' : '70vh'"
:height="isMobile ? '100vh' : '80vh'"
placement="bottom"
block-scroll
mask-closable
@@ -9,41 +9,73 @@
@mask-click="close"
>
<div class="music-page">
<div class="music-close">
<i class="icon iconfont icon-icon_error" @click="close"></i>
<div class="music-header h-12 flex items-center justify-between">
<n-ellipsis :line-clamp="1">
<div class="music-title">
{{ name }}
</div>
</n-ellipsis>
<div class="music-close">
<i class="icon iconfont icon-icon_error" @click="close"></i>
</div>
</div>
<div class="music-title text-el">{{ name }}</div>
<!-- 歌单歌曲列表 -->
<div v-loading="loading" class="music-list">
<n-virtual-list
v-if="displayedSongs.length"
ref="virtualListRef"
:items="displayedSongs"
:item-size="60"
:keep-alive="true"
:min-size="5"
:style="{ height: listHeight }"
@scroll="handleScroll"
>
<template #default="{ item }">
<song-item :item="formatDetail(item)" @play="handlePlay" />
</template>
</n-virtual-list>
<div v-else-if="loading" class="loading-more">加载中...</div>
<play-bottom />
<div class="music-content">
<!-- 左侧歌单信息 -->
<div class="music-info">
<div class="music-cover">
<n-image
:src="getImgUrl(listInfo?.coverImgUrl, '300y300')"
class="cover-img"
preview-disabled
:class="setAnimationClass('animate__fadeIn')"
object-fit="cover"
/>
</div>
<div class="music-detail">
<div v-if="listInfo?.creator" class="creator-info">
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div>
<div v-if="listInfo?.description" class="music-desc">
<n-ellipsis :line-clamp="isMobile ? 3 : 10">
{{ listInfo.description }}
</n-ellipsis>
</div>
</div>
</div>
<!-- 右侧歌曲列表 -->
<div class="music-list-container">
<div v-loading="loading" class="music-list">
<n-scrollbar @scroll="handleScroll">
<div v-loading="loading || !songList.length" class="music-list-content">
<div
v-for="(item, index) in displayedSongs"
:key="item.id"
class="double-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<play-bottom />
</div>
</n-scrollbar>
</div>
<play-bottom />
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
// 导入 NVirtualListInst 类型
import type { VirtualListInst } from 'naive-ui';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { isMobile } from '@/utils';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
@@ -136,8 +168,10 @@ const loadMoreSongs = async () => {
}
};
// 添加虚拟列表的引用
const virtualListRef = ref<VirtualListInst | null>(null);
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 20);
};
// 修改滚动处理函数
const handleScroll = (e: Event) => {
@@ -162,35 +196,62 @@ watch(
},
{ immediate: true },
);
// 添加计算属性来处理列表高度
const listHeight = computed(() => {
const baseHeight = '100%'; // 减去标题高度
return store.state.isPlay ? `calc(100% - 90px)` : baseHeight; // 112px 是 PlayBottom 的高度
});
</script>
<style scoped lang="scss">
.music {
&-title {
@apply text-xl font-bold text-white;
}
&-page {
@apply px-8 w-full h-full bg-black bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px);
}
&-title {
@apply text-lg font-bold text-white p-4;
}
&-close {
@apply absolute top-4 right-8 cursor-pointer text-white flex gap-2 items-center;
@apply cursor-pointer text-white flex gap-2 items-center;
.icon {
@apply text-3xl;
}
}
&-content {
@apply flex h-[calc(100%-60px)];
}
&-info {
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
.music-cover {
@apply w-full aspect-square rounded-lg overflow-hidden mb-4;
.cover-img {
@apply w-full h-full object-cover;
}
}
.music-detail {
@apply flex flex-col flex-grow;
.creator-info {
@apply flex items-center mb-4;
.creator-name {
@apply ml-2 text-sm text-gray-300;
}
}
.music-desc {
@apply text-sm text-gray-400;
}
}
}
&-list-container {
@apply flex-grow min-h-0 flex flex-col relative;
}
&-list {
height: calc(100% - 60px);
position: relative; // 添加相对定位
@apply flex-grow min-h-0;
:deep(.n-virtual-list__scroll) {
scrollbar-width: none;
@@ -205,6 +266,21 @@ const listHeight = computed(() => {
.music-page {
@apply px-4;
}
.music-content {
@apply flex-col;
}
.music-info {
@apply w-full pr-0 mb-2 flex flex-row;
.music-cover {
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
}
.music-detail {
@apply flex-1 ml-4;
}
}
}
.loading-more {
@@ -220,12 +296,4 @@ const listHeight = computed(() => {
background-color: #191919;
}
}
// 确保 PlayBottom 不会影响滚动区域
:deep(.bottom) {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
</style>

View File

@@ -90,7 +90,7 @@
</div>
<div class="right-controls">
<div class="volume-control custom-slider">
<div v-if="!isMobile" class="volume-control custom-slider">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleMute">
@@ -172,7 +172,7 @@
<script setup lang="ts">
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { getMvUrl } from '@/api/mv';
@@ -300,6 +300,7 @@ onUnmounted(() => {
if (cursorTimer) {
clearTimeout(cursorTimer);
}
unlockScreenOrientation();
});
// 监听 currentMv 的变化
@@ -390,7 +391,28 @@ const checkFullscreenAPI = () => {
};
};
// 切换全屏状态
// 添加横屏锁定功能
const lockScreenOrientation = async () => {
try {
if ('orientation' in screen) {
await (screen as any).orientation.lock('landscape');
}
} catch (error) {
console.warn('无法锁定屏幕方向:', error);
}
};
const unlockScreenOrientation = () => {
try {
if ('orientation' in screen) {
(screen as any).orientation.unlock();
}
} catch (error) {
console.warn('无法解锁屏幕方向:', error);
}
};
// 修改切换全屏状态的方法
const toggleFullscreen = async () => {
const api = checkFullscreenAPI();
@@ -403,9 +425,17 @@ const toggleFullscreen = async () => {
if (!api.fullscreenElement) {
await videoContainerRef.value?.requestFullscreen();
isFullscreen.value = true;
// 在移动端进入全屏时锁定横屏
if (window.innerWidth <= 768) {
await lockScreenOrientation();
}
} else {
await document.exitFullscreen();
isFullscreen.value = false;
// 退出全屏时解锁屏幕方向
if (window.innerWidth <= 768) {
unlockScreenOrientation();
}
}
} catch (error) {
console.error('切换全屏失败:', error);
@@ -513,16 +543,54 @@ watch(showControls, (newValue) => {
resetCursorTimer();
}
});
const isMobile = computed(() => store.state.isMobile);
</script>
<style scoped lang="scss">
.mv-detail {
@apply w-full h-full bg-black relative;
// 添加横屏模式支持
@media screen and (orientation: landscape) {
height: 100vh !important;
width: 100vw !important;
}
.video-container {
@apply w-full h-full relative;
transition: cursor 0.3s ease;
// 移动端适配
@media (max-width: 768px) {
.custom-controls {
.controls-main {
@apply flex-wrap gap-2 justify-center;
.left-controls,
.right-controls {
@apply w-full justify-center;
}
.time-display {
@apply order-first w-full text-center mb-2;
}
}
}
// 调整标题样式
.mv-detail-title {
.title {
@apply text-base max-w-full;
}
}
// 调整进度条
.progress-bar {
@apply mb-2;
}
}
&.cursor-hidden {
* {
cursor: none !important;

View File

@@ -1,6 +1,6 @@
<template>
<div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">最新专辑</div>
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
<div class="recommend-album-list">
<template v-for="(item, index) in albumData?.albums" :key="item.id">
<div

View File

@@ -89,7 +89,6 @@ const loadData = async () => {
const {
data: { data: dayRecommend },
} = await getDayRecommend();
console.log('dayRecommend', dayRecommend);
// 处理数据
if (dayRecommend) {
singerData.artists = singerData.artists.slice(0, 4);

View File

@@ -7,7 +7,8 @@
</div>
<div class="app-info">
<h2 class="app-name">Alger Music</h2>
<p class="app-desc">在桌面安装应用获得更好的体验</p>
<p class="app-desc mb-2">在桌面安装应用获得更好的体验</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="modal-actions">
@@ -23,29 +24,33 @@ import { onMounted, ref } from 'vue';
const showModal = ref(false);
const isElectron = ref((window as any).electron !== undefined);
const noPrompt = ref(false);
const closeModal = () => {
showModal.value = false;
localStorage.setItem('installPromptDismissed', 'true');
if (noPrompt.value) {
localStorage.setItem('installPromptDismissed', 'true');
}
};
const handleInstall = async () => {
// 新页面打开
// 识别当前环境是 mac 还是 windows
const os = navigator.platform;
const isMac = os.includes('Mac');
const isWindows = os.includes('Win');
const urls = {
mac: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.dmg',
windows: 'http://file.alger.fun/d/ali/%E8%BD%AF%E4%BB%B6/AlgerMusic/AlgerMusic.exe',
};
// 根据操作系统选择下载链接
let downloadUrl = '';
if (isMac) {
downloadUrl = urls.mac;
} else if (isWindows) {
downloadUrl = urls.windows;
}
// 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');
}

View File

@@ -15,6 +15,8 @@
<script setup lang="ts">
import { useStore } from 'vuex';
import { audioService } from '@/services/audioService';
const props = defineProps<{
show: boolean;
title: string;
@@ -29,6 +31,7 @@ watch(
if (val) {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
}
},
);

View File

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

View File

@@ -35,8 +35,8 @@
</template>
</div>
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
<div class="song-item-operating-like">
<i class="iconfont icon-likefill"></i>
<div v-if="favorite" class="song-item-operating-like">
<i class="iconfont icon-likefill" :class="{ 'like-active': isFavorite }" @click.stop="toggleFavorite"></i>
</div>
<div
class="song-item-operating-play bg-black animate__animated"
@@ -51,9 +51,10 @@
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import { audioService } from '@/services/audioService';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
@@ -63,10 +64,12 @@ const props = withDefaults(
item: SongResult;
mini?: boolean;
list?: boolean;
favorite?: boolean;
}>(),
{
mini: false,
list: false,
favorite: true,
},
);
@@ -103,8 +106,10 @@ const playMusicEvent = async (item: SongResult) => {
if (playMusic.value.id === item.id) {
if (play.value) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
return;
}
@@ -112,6 +117,21 @@ const playMusicEvent = async (item: SongResult) => {
store.commit('setIsPlay', true);
emits('play', item);
};
// 判断是否已收藏
const isFavorite = computed(() => {
return store.state.favoriteList.includes(props.item.id);
});
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
if (isFavorite.value) {
store.commit('removeFromFavorite', props.item.id);
} else {
store.commit('addToFavorite', props.item.id);
}
};
</script>
<style lang="scss" scoped>
@@ -139,7 +159,7 @@ const playMusicEvent = async (item: SongResult) => {
}
}
&-operating {
@apply flex items-center pl-4 rounded-full border border-gray-700 ml-4;
@apply flex items-center rounded-full border border-gray-700 ml-4;
background-color: #0d0d0d;
.iconfont {
@apply text-xl;
@@ -149,7 +169,10 @@ const playMusicEvent = async (item: SongResult) => {
@apply text-xl hover:text-red-600 transition;
}
&-like {
@apply mr-2 cursor-pointer;
@apply mr-2 cursor-pointer ml-4;
}
.like-active {
@apply text-red-600;
}
&-play {
@apply cursor-pointer border border-gray-500 rounded-full w-10 h-10 flex justify-center items-center hover:bg-green-600 transition;
@@ -180,7 +203,7 @@ const playMusicEvent = async (item: SongResult) => {
@apply text-base;
}
&-like {
@apply mr-1;
@apply mr-1 ml-1;
}
&-play {
@apply w-8 h-8;

View File

@@ -1,5 +1,6 @@
import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import store from '@/store';
import type { ILyricText, SongResult } from '@/type/music';
@@ -14,8 +15,34 @@ export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词
export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const audio = ref<HTMLAudioElement>(); // 音频对象
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound());
document.onkeyup = (e) => {
switch (e.code) {
case 'Space':
if (store.state.play) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
break;
default:
}
};
watch(
() => store.state.playMusicUrl,
(newVal) => {
if (newVal) {
audioService.play(newVal);
sound.value = audioService.getCurrentSound();
audioServiceOn(audioService);
}
},
);
watch(
() => store.state.playMusic,
@@ -29,6 +56,48 @@ watch(
deep: true,
},
);
export const audioServiceOn = (audio: typeof audioService) => {
let interval: any = null;
// 监听播放
audio.onPlay(() => {
store.commit('setPlayMusic', true);
interval = setInterval(() => {
nowTime.value = sound.value?.seek() as number;
allTime.value = sound.value?.duration() as number;
const newIndex = getLrcIndex(nowTime.value);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0;
}
if (isElectron.value) {
sendLyricToWin();
}
}, 50);
});
// 监听暂停
audio.onPause(() => {
store.commit('setPlayMusic', false);
clearInterval(interval);
});
// 监听结束
audio.onEnd(() => {
handleEnded();
store.commit('nextPlay');
});
};
export const play = () => {
audioService.getCurrentSound()?.play();
};
export const pause = () => {
audioService.getCurrentSound()?.pause();
};
const isPlaying = computed(() => store.state.play as boolean);
// 增加矫正时间
@@ -78,25 +147,18 @@ export const getLrcStyle = (index: number) => {
return {};
};
watch(nowTime, (newTime) => {
const newIndex = getLrcIndex(newTime);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0; // 重置进度
}
});
// 播放进度
export const useLyricProgress = () => {
let animationFrameId: number | null = null;
const updateProgress = () => {
if (!isPlaying.value) return;
audio.value = audio.value || (document.querySelector('#MusicAudio') as HTMLAudioElement);
if (!audio.value) return;
const currentSound = sound.value;
if (!currentSound) return;
const { start, end } = currentLrcTiming.value;
const duration = end - start;
const elapsed = audio.value.currentTime - start;
const elapsed = (currentSound.seek() as number) - start;
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
animationFrameId = requestAnimationFrame(updateProgress);
@@ -140,9 +202,12 @@ export const useLyricProgress = () => {
};
// 设置当前播放时间
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index];
audio.play();
export const setAudioTime = (index: number) => {
const currentSound = sound.value;
if (!currentSound) return;
currentSound.seek(lrcTimeArray.value[index]);
currentSound.play();
};
// 获取当前播放的歌词
@@ -154,7 +219,7 @@ export const getCurrentLrc = () => {
};
};
// 获取一句歌词播放时间几秒到几秒
// 获取一句歌词播放时间几秒到几秒
export const getLrcTimeRange = (index: number) => ({
currentTime: lrcTimeArray.value[index],
nextTime: lrcTimeArray.value[index + 1],
@@ -180,26 +245,9 @@ watch(isPlaying, (newIsPlaying) => {
}
});
// 监听时间变化
watch(nowTime, (newTime) => {
const newIndex = getLrcIndex(newTime);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0; // 重置进度
// 当索引变化时发送更新
if (isElectron.value) {
sendLyricToWin();
}
}
});
// 处理歌曲结束
export const handleEnded = () => {
// ... 原有的结束处理逻辑 ...
// 如果有歌词窗口,发送初始化数据
if (isElectron.value) {
// 延迟一下等待新歌曲加载完成
setTimeout(() => {
initLyricWindow();
sendLyricToWin();

View File

@@ -1,5 +1,8 @@
import { Howl } from 'howler';
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { audioService } from '@/services/audioService';
import type { ILyric, ILyricText, SongResult } from '@/type/music';
import { getImgUrl, getMusicProxyUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
@@ -53,9 +56,13 @@ export const useMusicListHook = () => {
// 用于预加载下一首歌曲的 MP3 数据
const preloadNextSong = (nextSongUrl: string) => {
const audio = new Audio(nextSongUrl);
audio.preload = 'auto'; // 设置预加载
audio.load(); // 手动加载
const sound = new Howl({
src: [nextSongUrl],
html5: true,
preload: true,
autoplay: false,
});
return sound;
};
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
@@ -166,9 +173,19 @@ export const useMusicListHook = () => {
state.playMusic.lyric = lyrics;
};
const play = () => {
audioService.getCurrentSound()?.play();
};
const pause = () => {
audioService.getCurrentSound()?.pause();
};
return {
handlePlayMusic,
nextPlay,
prevPlay,
play,
pause,
};
};

View File

@@ -64,68 +64,9 @@ const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
const { menus } = store.state;
const play = computed(() => store.state.play as boolean);
const route = useRoute();
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const backgroundColor = ref('#000');
// watch(
// () => store.state.playMusic,
// () => {
// backgroundColor.value = store.state.playMusic.backgroundColor;
// console.log('backgroundColor.value', backgroundColor.value);
// },
// {
// immediate: true,
// deep: true,
// },
// );
onMounted(() => {
// 监听音乐是否播放
watch(
() => play.value,
(value) => {
if (value && audio.value) {
audioPlay();
} else {
audioPause();
}
},
);
document.onkeyup = (e) => {
switch (e.code) {
case 'Space':
playMusicEvent();
break;
default:
}
};
});
const audioPlay = () => {
if (audio.value) {
audio.value.play();
}
};
const audioPause = () => {
if (audio.value) {
audio.value.pause();
}
};
const playMusicEvent = async () => {
if (play.value) {
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true);
}
};
</script>
<style lang="scss" scoped>

View File

@@ -34,7 +34,7 @@
:key="index"
class="music-lrc-text"
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@click="setAudioTime(index, audio)"
@click="setAudioTime(index)"
>
<span :style="getLrcStyle(index)">{{ item.text }}</span>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
@@ -75,10 +75,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
audio: {
type: HTMLAudioElement,
default: null,
},
background: {
type: String,
default: '',
@@ -258,10 +254,9 @@ defineExpose({
background-color: transparent;
span {
padding-right: 100px;
// display: inline-block;
background-clip: text !important;
-webkit-background-clip: text !important;
padding-right: 30px;
}
&-tr {
@@ -286,12 +281,19 @@ defineExpose({
.mobile {
#drawer-target {
@apply flex-col p-4 pt-8;
@apply flex-col p-4 pt-8 justify-start;
.music-img {
display: none;
}
.music-lrc {
height: calc(100vh - 260px) !important;
width: 100vw;
span {
padding-right: 0px !important;
}
}
.music-lrc-text {
text-align: center;
}
}
}

View File

@@ -1,23 +1,21 @@
<template>
<!-- 展开全屏 -->
<music-full
ref="MusicFullRef"
v-model:music-full="musicFullVisible"
:audio="audio.value as HTMLAudioElement"
:background="background"
/>
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
<!-- 底部播放栏 -->
<div
class="music-play-bar"
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
>
<n-image
:src="getImgUrl(playMusic?.picUrl, '300y300')"
class="play-bar-img"
lazy
preview-disabled
@click="setMusicFull"
/>
<div class="play-bar-img-wrapper" @click="setMusicFull">
<n-image :src="getImgUrl(playMusic?.picUrl, '300y300')" class="play-bar-img" lazy preview-disabled />
<div class="hover-arrow">
<div class="hover-content">
<!-- <i class="ri-arrow-up-s-line text-3xl" :class="{ 'ri-arrow-down-s-line': musicFullVisible }"></i> -->
<i class="text-3xl" :class="musicFullVisible ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'"></i>
<span class="hover-text">{{ musicFullVisible ? '收起' : '展开' }}歌词</span>
</div>
</div>
</div>
<div class="music-content">
<div class="music-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">
@@ -109,11 +107,12 @@
</template>
<script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core';
import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, getCurrentLrc, isElectron, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
import { allTime, isElectron, nowTime, openLyric, sound } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
@@ -125,12 +124,8 @@ const store = useStore();
const playMusic = computed(() => store.state.playMusic as SongResult);
// 是否播放
const play = computed(() => store.state.play as boolean);
const playList = computed(() => store.state.playList as SongResult[]);
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const background = ref('#000');
watch(
@@ -141,30 +136,27 @@ watch(
{ immediate: true, deep: true },
);
const audioPlay = () => {
if (audio.value) {
audio.value.play();
}
};
// 使用 useThrottleFn 创建节流版本的 seek 函数
const throttledSeek = useThrottleFn((value: number) => {
if (!sound.value) return;
sound.value.seek((value * allTime.value) / 100);
}, 50); // 50ms 的节流延迟
// 计算属性 获取当前播放时间的进度
// 修改 timeSlider 计算属性
const timeSlider = computed({
get: () => (nowTime.value / allTime.value) * 100,
set: (value) => {
if (!audio.value) return;
audio.value.currentTime = (value * allTime.value) / 100;
audioPlay();
store.commit('setPlayMusic', true);
},
set: throttledSeek,
});
// 音量条
const audioVolume = ref(1);
const audioVolume = ref(localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1);
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
if (!audio.value) return;
audio.value.volume = value / 100;
if (!sound.value) return;
localStorage.setItem('volume', (value / 100).toString());
sound.value.volume(value / 100);
audioVolume.value = value / 100;
},
});
// 获取当前播放时间
@@ -177,25 +169,6 @@ const getAllTime = computed(() => {
return secondToMinute(allTime.value);
});
// 监听音乐播放 获取时间
const onAudio = () => {
if (audio.value) {
audio.value.removeEventListener('timeupdate', handleGetAudioTime);
audio.value.removeEventListener('ended', handleEnded);
audio.value.addEventListener('timeupdate', handleGetAudioTime);
audio.value.addEventListener('ended', handleEnded);
// 监听音乐播放暂停
audio.value.addEventListener('pause', () => {
store.commit('setPlayMusic', false);
});
audio.value.addEventListener('play', () => {
store.commit('setPlayMusic', true);
});
}
};
onAudio();
function handleEnded() {
store.commit('nextPlay');
}
@@ -206,27 +179,17 @@ function handlePrev() {
const MusicFullRef = ref<any>(null);
function handleGetAudioTime(this: HTMLAudioElement) {
// 监听音频播放的实时时间事件
const audio = this as HTMLAudioElement;
// 获取当前播放时间
nowTime.value = audio.currentTime;
getCurrentLrc();
// 获取总时间
allTime.value = audio.duration;
// 获取音量
audioVolume.value = audio.volume;
sendLyricToWin(store.state.isPlay);
// if (musicFullVisible.value) {
// MusicFullRef.value?.lrcScroll();
// }
}
// 播放暂停按钮事件
const playMusicEvent = async () => {
if (play.value) {
if (sound.value) {
sound.value.pause();
}
store.commit('setPlayMusic', false);
} else {
if (sound.value) {
sound.value.play();
}
store.commit('setPlayMusic', true);
}
};
@@ -409,4 +372,39 @@ const scrollToPlayList = (val: boolean) => {
:root {
--primary-color: #18a058;
}
.play-bar-img-wrapper {
@apply relative cursor-pointer w-14 h-14;
.hover-arrow {
@apply absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 rounded-2xl;
background: rgba(0, 0, 0, 0.5);
.hover-content {
@apply flex flex-col items-center justify-center;
i {
@apply text-white mb-0.5;
}
.hover-text {
@apply text-white text-xs scale-90;
}
}
}
&:hover {
.hover-arrow {
@apply opacity-100;
}
}
}
.tooltip-content {
@apply text-sm py-1 px-2;
}
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}
</style>

View File

@@ -35,14 +35,14 @@
/>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
</div>
<n-tooltip v-if="!isElectron">
<template #trigger>
<div class="github" @click="toGithub">
<i class="ri-github-fill"></i>
</div>
</template>
<div>前往 Github</div>
</n-tooltip>
<coffee
alipay-q-r="https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true"
wechat-q-r="https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true"
>
<div class="github" @click="toGithub">
<i class="ri-github-fill"></i>
</div>
</coffee>
</div>
</template>
@@ -52,8 +52,8 @@ import { useStore } from 'vuex';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { isElectron } from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
const router = useRouter();
@@ -169,6 +169,6 @@ const toGithub = () => {
}
.github {
@apply cursor-pointer text-gray-100 hover:text-gray-400 text-xl ml-4 rounded-full border border-gray-600 flex justify-center items-center px-2;
@apply cursor-pointer text-gray-100 hover:text-gray-400 text-xl ml-4 rounded-full border border-gray-600 flex justify-center items-center px-2 h-full;
}
</style>

View File

@@ -50,6 +50,15 @@ const layoutRouter = [
},
component: () => import('@/views/history/index.vue'),
},
{
path: '/favorite',
name: 'favorite',
component: () => import('@/views/favorite/index.vue'),
meta: {
title: '我的收藏',
icon: 'icon-likefill',
},
},
{
path: '/user',
name: 'user',

View File

@@ -0,0 +1,55 @@
import { Howl } from 'howler';
class AudioService {
private currentSound: Howl | null = null;
play(url: string) {
if (this.currentSound) {
this.currentSound.unload();
}
this.currentSound = null;
this.currentSound = new Howl({
src: [url],
html5: true,
autoplay: true,
volume: localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1,
});
return this.currentSound;
}
getCurrentSound() {
return this.currentSound;
}
stop() {
if (this.currentSound) {
this.currentSound.stop();
this.currentSound.unload();
this.currentSound = null;
}
}
// 监听播放
onPlay(callback: () => void) {
if (this.currentSound) {
this.currentSound.on('play', callback);
}
}
// 监听暂停
onPause(callback: () => void) {
if (this.currentSound) {
this.currentSound.on('pause', callback);
}
}
// 监听结束
onEnd(callback: () => void) {
if (this.currentSound) {
this.currentSound.on('end', callback);
}
}
}
export const audioService = new AudioService();

View File

@@ -4,6 +4,15 @@ import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
import type { SongResult } from '@/type/music';
// 默认设置
const defaultSettings = {
isProxy: false,
noAnimate: false,
animationSpeed: 1,
author: 'Alger',
authorUrl: 'https://github.com/algerkong',
};
interface State {
menus: any[];
play: boolean;
@@ -18,6 +27,7 @@ interface State {
isMobile: boolean;
searchValue: string;
searchType: number;
favoriteList: number[];
}
const state: State = {
@@ -29,11 +39,12 @@ const state: State = {
user: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') as string) : null,
playList: [],
playListIndex: 0,
setData: null,
setData: defaultSettings,
lyric: {},
isMobile: false,
searchValue: '',
searchType: 1,
favoriteList: localStorage.getItem('favoriteList') ? JSON.parse(localStorage.getItem('favoriteList') || '[]') : [],
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
@@ -61,10 +72,44 @@ const mutations = {
async prevPlay(state: State) {
await prevPlay(state);
},
async setSetData(state: State, setData: any) {
setSetData(state: State, setData: any) {
state.setData = setData;
if ((window as any).electron) {
const isElectron = (window as any).electronAPI !== undefined;
if (isElectron) {
(window as any).electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
} else {
localStorage.setItem('appSettings', JSON.stringify(setData));
}
},
addToFavorite(state: State, songId: number) {
if (!state.favoriteList.includes(songId)) {
state.favoriteList = [songId, ...state.favoriteList];
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
}
},
removeFromFavorite(state: State, songId: number) {
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
},
};
const actions = {
initializeSettings({ commit }: { commit: any }) {
const isElectron = (window as any).electronAPI !== undefined;
if (isElectron) {
const setData = (window as any).electron.ipcRenderer.getStoreValue('set');
commit('setSetData', setData || defaultSettings);
} else {
const savedSettings = localStorage.getItem('appSettings');
if (savedSettings) {
commit('setSetData', {
...defaultSettings,
...JSON.parse(savedSettings),
});
} else {
commit('setSetData', defaultSettings);
}
}
},
};
@@ -72,6 +117,7 @@ const mutations = {
const store = createStore({
state,
mutations,
actions,
});
export default store;

View File

@@ -11,11 +11,20 @@ export const setAnimationClass = (type: String) => {
if (store.state.setData && store.state.setData.noAnimate) {
return '';
}
return `animate__animated ${type}`;
const speed = store.state.setData?.animationSpeed || 1;
let speedClass = '';
if (speed <= 0.3) speedClass = 'animate__slower';
else if (speed <= 0.8) speedClass = 'animate__slow';
else if (speed >= 2.5) speedClass = 'animate__faster';
else if (speed >= 1.5) speedClass = 'animate__fast';
return `animate__animated ${type}${speedClass ? ` ${speedClass}` : ''}`;
};
// 设置动画延时
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
return `animation-delay:${index * time}ms`;
const speed = store.state.setData?.animationSpeed || 1;
return `animation-delay:${(index * time) / (speed * 2)}ms`;
};
// 将秒转换为分钟和秒

View File

@@ -0,0 +1,208 @@
<template>
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
<div class="favorite-header" :class="setAnimationClass('animate__fadeInRight')">
<h2>我的收藏</h2>
<div class="favorite-count"> {{ favoriteList.length }} </div>
</div>
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')">
<n-scrollbar class="favorite-content">
<div v-if="favoriteList.length === 0" class="empty-tip">
<n-empty description="还没有收藏歌曲" />
</div>
<div v-else class="favorite-list">
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<template v-else>
<song-item
v-for="(song, index) in favoriteSongs"
:key="song.id"
:item="song"
:favorite="!isComponent"
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
@play="handlePlay"
/>
</template>
<div v-if="isComponent" class="favorite-list-more text-center">
<n-button text type="primary" @click="handleMore">查看更多</n-button>
</div>
</div>
</n-scrollbar>
<div v-if="favoriteList.length > 0 && !loading && !isComponent" class="pagination-wrapper">
<n-pagination
v-model:page="currentPage"
:page-size="pageSize"
:item-count="favoriteList.length"
:page-slot="5"
size="small"
@update:page="handlePageChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
const store = useStore();
const favoriteList = computed(() => store.state.favoriteList);
const favoriteSongs = ref<SongResult[]>([]);
const loading = ref(false);
// 分页相关
const pageSize = 16;
const currentPage = ref(1);
defineProps({
isComponent: {
type: Boolean,
default: false,
},
});
// 获取当前页的收藏歌曲ID
const getCurrentPageIds = () => {
// 反转列表顺序,最新收藏的在前面
const reversedList = [...favoriteList.value];
const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize;
return reversedList.slice(startIndex, endIndex);
};
// 获取收藏歌曲详情
const getFavoriteSongs = async () => {
if (favoriteList.value.length === 0) {
favoriteSongs.value = [];
return;
}
loading.value = true;
try {
const currentIds = getCurrentPageIds();
const res = await getMusicDetail(currentIds);
if (res.data.songs) {
favoriteSongs.value = res.data.songs.map((song: SongResult) => {
return {
...song,
picUrl: song.al?.picUrl || '',
};
});
}
} catch (error) {
console.error('获取收藏歌曲失败:', error);
} finally {
loading.value = false;
}
};
// 处理页码变化
const handlePageChange = () => {
getFavoriteSongs();
};
onMounted(() => {
getFavoriteSongs();
});
// 监听收藏列表变化
watch(
favoriteList,
() => {
currentPage.value = 1;
getFavoriteSongs();
},
{ deep: true, immediate: true },
);
const handlePlay = () => {
store.commit('setPlayList', favoriteSongs.value);
};
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 30);
};
const router = useRouter();
const handleMore = () => {
router.push('/favorite');
};
</script>
<style lang="scss" scoped>
.favorite-page {
@apply h-full flex flex-col p-6;
.favorite-header {
@apply flex items-center justify-between mb-6 flex-shrink-0;
h2 {
@apply text-2xl font-bold;
}
.favorite-count {
@apply text-gray-400 text-sm;
}
}
.favorite-main {
@apply flex flex-col flex-grow min-h-0;
.favorite-content {
@apply flex-grow min-h-0;
.empty-tip {
@apply h-full flex items-center justify-center;
}
.favorite-list {
@apply space-y-2 pb-4;
}
}
}
}
.loading-wrapper {
@apply flex justify-center items-center py-20;
}
.pagination-wrapper {
@apply flex justify-center py-4 flex-shrink-0;
:deep(.n-pagination) {
@apply bg-gray-800 rounded-full px-4 py-1;
.n-pagination-item {
@apply text-gray-300 hover:text-white;
&--active {
@apply text-green-500;
}
}
}
}
.mobile {
.favorite-page {
@apply p-4;
.favorite-header {
@apply mb-4;
h2 {
@apply text-xl;
}
}
}
}
</style>

View File

@@ -9,7 +9,10 @@
<!-- 本周最热音乐 -->
<recommend-songlist />
<!-- 推荐最新专辑 -->
<recommend-album />
<div>
<favorite-list is-component />
<recommend-album />
</div>
</div>
</div>
</n-scrollbar>
@@ -17,11 +20,8 @@
<script lang="ts" setup>
import { isMobile } from '@/utils';
import FavoriteList from '@/views/favorite/index.vue';
const RecommendSinger = defineAsyncComponent(() => import('@/components/RecommendSinger.vue'));
const PlaylistType = defineAsyncComponent(() => import('@/components/PlaylistType.vue'));
const RecommendSonglist = defineAsyncComponent(() => import('@/components/RecommendSonglist.vue'));
const RecommendAlbum = defineAsyncComponent(() => import('@/components/RecommendAlbum.vue'));
defineOptions({
name: 'Home',
});
@@ -35,7 +35,22 @@ defineOptions({
@apply mt-6 flex mb-28;
}
.mobile .main-content {
@apply flex-col mx-4;
.mobile {
.main-content {
@apply flex-col mx-4;
}
:deep(.favorite-page) {
@apply p-0 mx-4 h-full;
}
}
:deep(.favorite-page) {
@apply p-0 mx-4 h-[300px];
.favorite-header {
@apply mb-0;
h2 {
@apply text-lg font-bold mb-4;
}
}
}
</style>

View File

@@ -11,13 +11,7 @@ defineOptions({
name: 'List',
});
const ITEMS_PER_ROW = ref(6); // 每行显示的数量
const TOTAL_ITEMS = 30; // 每页数量
// 计算实际需要加载的数量,确保能被每行数量整除
const getAdjustedLimit = (perRow: number) => {
return Math.ceil(TOTAL_ITEMS / perRow) * perRow;
};
const TOTAL_ITEMS = 42; // 每数量
const recommendList = ref<any[]>([]);
const showMusic = ref(false);
@@ -25,10 +19,9 @@ const page = ref(0);
const hasMore = ref(true);
const isLoadingMore = ref(false);
// 计算每个项目在当前页面中的索引
// 计算每个项目的动画延迟
const getItemAnimationDelay = (index: number) => {
const adjustedLimit = getAdjustedLimit(ITEMS_PER_ROW.value);
const currentPageIndex = index % adjustedLimit;
const currentPageIndex = index % TOTAL_ITEMS;
return setAnimationDelay(currentPageIndex, 30);
};
@@ -62,11 +55,10 @@ const loadList = async (type: string, isLoadMore = false) => {
}
try {
const adjustedLimit = getAdjustedLimit(ITEMS_PER_ROW.value);
const params = {
cat: type || '',
limit: adjustedLimit,
offset: page.value * adjustedLimit,
limit: TOTAL_ITEMS,
offset: page.value * TOTAL_ITEMS,
};
const { data } = await getListByCat(params);
if (isLoadMore) {
@@ -93,31 +85,16 @@ const handleScroll = (e: any) => {
}
};
// 监听窗口大小变化,调整每行显示数量
const updateItemsPerRow = () => {
const width = window.innerWidth;
if (width > 1800) ITEMS_PER_ROW.value = 8;
else if (width > 1200) ITEMS_PER_ROW.value = 8;
else if (width > 768) ITEMS_PER_ROW.value = 6;
else ITEMS_PER_ROW.value = 5;
};
onMounted(() => {
updateItemsPerRow();
window.addEventListener('resize', updateItemsPerRow);
if (route.query.type) {
loadList(route.query.type as string);
} else {
getRecommendList(getAdjustedLimit(ITEMS_PER_ROW.value)).then((res: { data: { result: any } }) => {
getRecommendList(TOTAL_ITEMS).then((res: { data: { result: any } }) => {
recommendList.value = res.data.result;
});
}
});
onUnmounted(() => {
window.removeEventListener('resize', updateItemsPerRow);
});
watch(
() => route.query,
async (newParams) => {
@@ -186,17 +163,17 @@ watch(
.recommend {
@apply w-full h-full bg-none;
&-title {
@apply text-lg font-bold text-white pb-4;
@apply text-lg font-bold text-white pb-2;
}
&-list {
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
grid-template-columns: repeat(v-bind(ITEMS_PER_ROW), minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
&-item {
@apply flex flex-col;
&-img {
@apply rounded-xl overflow-hidden relative w-full;
@apply rounded-xl overflow-hidden relative w-full aspect-square;
&-img {
@apply block w-full h-full;
}
@@ -240,4 +217,15 @@ watch(
.no-more {
@apply text-center py-4 text-sm text-gray-500;
}
.mobile {
.recommend-title {
@apply text-xl font-bold px-4;
}
.recommend-list {
@apply px-4 gap-4;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
</style>

View File

@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { checkQr, createQr, getQrKey, getUserDetail, loginByCellphone } from '@/api/login';
import { isMobile, setAnimationClass } from '@/utils';
import { setAnimationClass } from '@/utils';
defineOptions({
name: 'Login',
@@ -14,6 +14,7 @@ defineOptions({
const message = useMessage();
const store = useStore();
const router = useRouter();
const isQr = ref(false);
const qrUrl = ref<string>();
onMounted(() => {
@@ -24,6 +25,11 @@ const timerRef = ref(null);
const loadLogin = async () => {
try {
if (timerRef.value) {
clearInterval(timerRef.value);
timerRef.value = null;
}
if (!isQr.value) return;
const qrKey = await getQrKey();
const key = qrKey.data.data.unikey;
const { data } = await createQr(key);
@@ -65,7 +71,7 @@ const timerIsQr = (key: string) => {
clearInterval(timer);
timerRef.value = null;
}
}, 2000);
}, 3000);
return timer;
};
@@ -79,9 +85,9 @@ onBeforeUnmount(() => {
});
// 是否扫码登陆
const isQr = ref(!isMobile.value);
const chooseQr = () => {
isQr.value = !isQr.value;
loadLogin();
};
// 手机号登录
@@ -116,6 +122,7 @@ const loginPhone = async () => {
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
<input v-model="password" class="phone-input" type="password" placeholder="密码" />
</div>
<div class="text">使用网易云账号登录</div>
<n-button class="btn-login" @click="loginPhone()">登录</n-button>
</div>
</div>
@@ -136,14 +143,14 @@ const loginPhone = async () => {
}
.text {
@apply mt-4 text-green-500 text-xs;
@apply mt-4 text-white text-xs;
}
.phone-login {
width: 350px;
height: 550px;
@apply rounded-2xl rounded-b-none bg-cover bg-no-repeat relative overflow-hidden;
background-image: url(http://tva4.sinaimg.cn/large/006opRgRgy1gw8nf6no7uj30rs15n0x7.jpg);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='400' height='560' preserveAspectRatio='none' viewBox='0 0 400 560'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1066%26quot%3b)' fill='none'%3e%3crect width='400' height='560' x='0' y='0' fill='rgba(24%2c 106%2c 59%2c 1)'%3e%3c/rect%3e%3cpath d='M0%2c234.738C43.535%2c236.921%2c80.103%2c205.252%2c116.272%2c180.923C151.738%2c157.067%2c188.295%2c132.929%2c207.855%2c94.924C227.898%2c55.979%2c233.386%2c10.682%2c226.119%2c-32.511C218.952%2c-75.107%2c199.189%2c-115.793%2c167.469%2c-145.113C137.399%2c-172.909%2c92.499%2c-171.842%2c55.779%2c-189.967C8.719%2c-213.196%2c-28.344%2c-282.721%2c-78.217%2c-266.382C-128.725%2c-249.834%2c-111.35%2c-166.696%2c-143.781%2c-124.587C-173.232%2c-86.348%2c-244.72%2c-83.812%2c-255.129%2c-36.682C-265.368%2c9.678%2c-217.952%2c48.26%2c-190.512%2c87.004C-167.691%2c119.226%2c-140.216%2c145.431%2c-109.013%2c169.627C-74.874%2c196.1%2c-43.147%2c232.575%2c0%2c234.738' fill='%23114b2a'%3e%3c/path%3e%3cpath d='M400 800.9010000000001C443.973 795.023 480.102 765.6 513.011 735.848 541.923 709.71 561.585 676.6320000000001 577.037 640.85 592.211 605.712 606.958 568.912 601.458 531.035 595.962 493.182 568.394 464.36400000000003 546.825 432.775 522.317 396.88300000000004 507.656 347.475 466.528 333.426 425.366 319.366 384.338 352.414 342.111 362.847 297.497 373.869 242.385 362.645 211.294 396.486 180.212 430.318 192.333 483.83299999999997 188.872 529.644 185.656 572.218 178.696 614.453 191.757 655.101 205.885 699.068 227.92 742.4110000000001 265.75 768.898 304.214 795.829 353.459 807.1220000000001 400 800.9010000000001' fill='%231f894c'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1066'%3e%3crect width='400' height='560' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
background-color: #383838;
box-shadow: inset 0px 0px 20px 5px #0000005e;

View File

@@ -13,14 +13,7 @@
:style="getItemAnimationDelay(index)"
>
<div class="mv-item-img" @click="handleShowMv(item, index)">
<n-image
class="mv-item-img-img"
:src="getImgUrl(item.cover, '200y112')"
lazy
preview-disabled
width="200"
height="112"
/>
<n-image class="mv-item-img-img" :src="getImgUrl(item.cover, '320y180')" lazy preview-disabled />
<div class="top">
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
<i class="iconfont icon-videofill"></i>
@@ -50,6 +43,7 @@ import { useStore } from 'vuex';
import { getTopMv } from '@/api/mv';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
@@ -65,7 +59,7 @@ const initLoading = ref(false);
const loadingMore = ref(false);
const currentIndex = ref(0);
const offset = ref(0);
const limit = ref(30);
const limit = ref(42);
const hasMore = ref(true);
const getItemAnimationDelay = (index: number) => {
@@ -80,6 +74,7 @@ onMounted(async () => {
const handleShowMv = async (item: IMvItem, index: number) => {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showMv.value = true;
currentIndex.value = index;
playMvItem.value = item;
@@ -165,12 +160,12 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
@apply relative h-full w-full;
&-title {
@apply text-xl font-bold;
@apply text-xl font-bold pb-2;
}
&-content {
@apply grid gap-6 pb-28 mt-2 pr-4;
grid-template-columns: repeat(auto-fill, minmax(14%, 1fr));
@apply grid gap-4 pb-28 mt-2 pr-4;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.mv-item {
@@ -178,6 +173,7 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
background-color: #1f1f1f;
&-img {
@apply rounded-lg overflow-hidden relative;
aspect-ratio: 16/9;
line-height: 0;
&:hover img {
@@ -185,7 +181,7 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
}
&-img {
@apply w-full rounded-lg overflow-hidden;
@apply w-full h-full object-cover rounded-lg overflow-hidden;
}
.top {
@@ -223,8 +219,13 @@ const isPrevDisabled = computed(() => currentIndex.value === 0);
}
.mobile {
.mv-list-title {
@apply text-xl font-bold px-4;
}
.mv-list-content {
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
@apply px-4;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}

View File

@@ -1,41 +1,84 @@
<template>
<div class="set-page">
<div class="set-item">
<div>
<div class="set-item-title">代理</div>
<div class="set-item-content">无法听音乐时打开</div>
<n-scrollbar>
<div class="set-page">
<div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">代理</div>
<div class="set-item-content">无法听音乐时打开</div>
</div>
<n-switch v-model:value="setData.isProxy" />
</div>
<n-switch v-model:value="setData.isProxy" />
</div>
<div class="set-item">
<div>
<div class="set-item-title">减轻动画效果</div>
<div class="set-item">
<div>
<div class="set-item-title">关闭动画效果</div>
</div>
<n-switch v-model:value="setData.noAnimate" />
</div>
<n-switch v-model:value="setData.noAnimate" />
</div>
<div class="set-item">
<div>
<div class="set-item-title">版本</div>
<div class="set-item-content">当前已是最新版本</div>
<div class="set-item">
<div>
<div class="set-item-title">动画速度</div>
<div class="set-item-content">调节动画播放速度</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
<div class="w-40">
<n-slider
v-model:value="setData.animationSpeed"
:min="0.1"
:max="3"
:step="0.1"
:marks="{
0.1: '极慢',
1: '正常',
3: '极快',
}"
:disabled="setData.noAnimate"
class="w-40"
/>
</div>
</div>
</div>
<div>{{ config.version }}</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">作者</div>
<div class="set-item-content"></div>
<div class="set-item">
<div>
<div class="set-item-title">版本</div>
<div class="set-item-content">当前已是最新版本</div>
</div>
<div>{{ config.version }}</div>
</div>
<div class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all" @click="openAuthor">
<div>
<div class="set-item-title">作者</div>
<div class="set-item-content">algerkong github</div>
</div>
<div>{{ setData.author }}</div>
</div>
<div>{{ setData.author }}</div>
</div>
<div class="set-action">
<n-button @click="handelCancel">取消</n-button>
<n-button type="primary" @click="handleSave">保存并重启</n-button>
<div class="set-action">
<n-button class="w-40 h-10" @click="handelCancel">取消</n-button>
<n-button type="primary" class="w-40 h-10" @click="handleSave">{{
isElectron ? '保存并重启' : '保存'
}}</n-button>
</div>
<div class="p-6 bg-black rounded-lg shadow-lg mt-20">
<div class="text-gray-100 text-base text-center">支持作者</div>
<div class="flex gap-60">
<div class="flex flex-col items-center gap-2 cursor-pointer hover:scale-[2] transition-all z-10 bg-black">
<n-image :src="alipayQR" alt="支付宝收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<span class="text-sm text-gray-100">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2 cursor-pointer hover:scale-[2] transition-all z-10 bg-black">
<n-image :src="wechatQR" alt="微信收款码" class="w-32 h-32 rounded-lg" preview-disabled />
<span class="text-sm text-gray-100">微信支付</span>
</div>
</div>
</div>
</div>
</div>
</n-scrollbar>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import config from '@/../package.json';
@@ -45,20 +88,32 @@ defineOptions({
name: 'Setting',
});
const setData = ref(store.state.setData);
const alipayQR = 'https://github.com/algerkong/algerkong/blob/main/alipay.jpg?raw=true';
const wechatQR = 'https://github.com/algerkong/algerkong/blob/main/wechat.jpg?raw=true';
const isElectron = ref((window as any).electronAPI !== undefined);
const router = useRouter();
// 使用计算属性来获取和设置数据
const setData = computed({
get: () => store.state.setData,
set: (value) => store.commit('setSetData', value),
});
const handelCancel = () => {
router.back();
};
const windowData = window as any;
const handleSave = () => {
store.commit('setSetData', setData.value);
if (windowData.electronAPI) {
windowData.electronAPI.restart();
if (isElectron.value) {
(window as any).electronAPI.restart();
}
router.back();
};
const openAuthor = () => {
window.open(setData.value.authorUrl, '_blank');
};
</script>
@@ -67,7 +122,7 @@ const handleSave = () => {
@apply flex flex-col justify-center items-center pt-8;
}
.set-item {
@apply w-3/5 flex justify-between items-center mb-4;
@apply w-3/5 flex justify-between items-center mb-2 px-4 py-2 rounded-lg;
.set-item-title {
@apply text-gray-200 text-base;
}
@@ -75,4 +130,7 @@ const handleSave = () => {
@apply text-gray-400 text-sm;
}
}
.set-action {
@apply flex gap-3 mt-4;
}
</style>

View File

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