Compare commits

...

22 Commits
1.5.1 ... 2.1.0

Author SHA1 Message Date
alger
dc12d895d8 feat: 2.1.0 2024-11-23 22:43:01 +08:00
alger
0bb14902f2 feat: 优化播放样式 优化歌曲背景色 优化 mv播放样式 添加循环播放 等控制功能 2024-11-23 22:42:23 +08:00
alger
3027a5f6ff feat: 完善mac打包规则 修复 icon显示问题 2024-11-20 22:44:17 +08:00
alger
f320f4760b feat: 添加网页标题修改 2024-11-01 17:39:18 +08:00
alger
e939933d6f feat: 添加减轻动画效果选项 添加indexdb方法 2024-10-22 21:09:51 +08:00
alger
06bffe7618 feat: 优化歌词页面样式 添加歌词进度显示 优化歌曲及列表加载方式 大幅提升歌曲歌词播放速度 2024-10-18 18:37:53 +08:00
alger
7abc087d70 feat: 添加播放列表自动滚动到播放的那个 2024-09-18 17:05:36 +08:00
alger
eb2ea1981d feat: 优化歌词背景色 加载问题 2024-09-18 15:11:20 +08:00
alger
6dc14ec51b feat: 优化歌词背景 修改为背景色 以解决卡顿问题 2024-09-14 18:22:56 +08:00
alger
36f8257a3e 🐞 fix: 上一首下一首逻辑错乱问题 2024-09-13 17:23:03 +08:00
alger
c55544df46 feat: 修复排行播放列表问题 优化暂停播放逻辑 2024-09-13 17:07:45 +08:00
alger
008f2183de 🐞 fix: 修复历史播放 不触发播放列表问题 2024-09-13 14:14:32 +08:00
alger
dd3a3c3bbb 🐞 fix: 类型问题修复 2024-09-13 14:11:02 +08:00
alger
941eb2e66e 🐞 fix: 修复作者不显示问题 2024-09-13 09:43:05 +08:00
alger
a98fcb43d6 🐞 fix: 修复播放列表无法显示问题 2024-09-13 09:08:57 +08:00
alger
791121ae06 feat: 优化搜索 2024-09-12 17:28:51 +08:00
alger
0c156e2708 feat: V1.7.0 2024-09-12 16:48:13 +08:00
alger
017b47fded 🐞 fix: 修复各种报错问题 2024-09-12 16:44:42 +08:00
alger
e27ed22c16 feat: 完善搜索歌单列表加载问题 2024-09-12 15:26:07 +08:00
alger
904d8744ef feat: 优化播放栏背景问题 2024-09-12 15:00:00 +08:00
alger
800e0b7360 feat: 完善歌单列表组件 实现滚动加载更多 2024-09-11 16:29:43 +08:00
alger
b6a5461a1d 🎈 perf: 优化加载 升级vue3.5 electron32等多个包 添加v-loading指令 2024-09-04 15:20:43 +08:00
42 changed files with 2550 additions and 495 deletions

View File

@@ -42,6 +42,9 @@
"no-console": "off",
"no-continue": "off",
"no-restricted-syntax": "off",
"no-return-assign": "off",
"no-unused-expressions": "off",
"no-return-await": "off",
"no-plusplus": "off",
"no-param-reassign": "off",
"no-shadow": "off",
@@ -126,7 +129,10 @@
"prefer-const": "error", // ts provides better types with const
"prefer-rest-params": "error", // ts provides better types with rest args over arguments
"prefer-spread": "error", // ts transpiles spread to apply, so no need for manual apply
"valid-typeof": "off" // ts(2367)
"valid-typeof": "off", // ts(2367)
"consistent-return": "off",
"no-promise-executor-return": "off",
"prefer-promise-reject-errors": "off"
}
}
]

2
.gitignore vendored
View File

@@ -14,3 +14,5 @@ package-lock.json
dist.zip
.vscode
bun.lockb

6
app.js
View File

@@ -20,11 +20,13 @@ function createWindow() {
win.setMinimumSize(1200, 780);
if (process.env.NODE_ENV === 'development') {
win.webContents.openDevTools({ mode: 'detach' });
win.loadURL('http://localhost:4678/');
win.loadURL('http://localhost:4488/');
} else {
win.loadURL(`file://${__dirname}/dist/index.html`);
}
const image = nativeImage.createFromPath(path.join(__dirname, 'public/icon.png'));
const image = nativeImage
.createFromPath(path.join(__dirname, 'public/icon_16x16.png'))
.resize({ width: 16, height: 16 });
const tray = new Tray(image);
// 创建一个上下文菜单

7
auto-imports.d.ts vendored
View File

@@ -3,6 +3,7 @@
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
@@ -35,6 +36,7 @@ declare global {
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
@@ -53,10 +55,13 @@ declare global {
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
@@ -65,6 +70,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

49
build/mac.json Normal file
View File

@@ -0,0 +1,49 @@
{
"appId": "com.alger.music",
"productName": "AlgerMusic",
"artifactName": "${productName}_${version}_${arch}.${ext}",
"directories": {
"output": "dist_electron/mac"
},
"files": [
"dist/**/*",
"package.json",
"app.js",
"electron/**/*",
"**/*",
"public/**/*",
"node_modules/**/*"
],
"mac": {
"icon": "public/icon.icns",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
}
],
"category": "public.app-category.music",
"darkModeSupport": true
},
"dmg": {
"title": "${productName} ${version}",
"icon": "public/icon.icns",
"contents": [
{
"x": 410,
"y": 150,
"type": "link",
"path": "/Applications"
},
{
"x": 130,
"y": 150,
"type": "file"
}
],
"window": {
"width": 540,
"height": 380
}
}
}

4
components.d.ts vendored
View File

@@ -1,14 +1,15 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
MPop: typeof import('./src/components/common/MPop.vue')['default']
MusicList: typeof import('./src/components/MusicList.vue')['default']
MvPlayer: typeof import('./src/components/MvPlayer.vue')['default']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
@@ -26,6 +27,7 @@ declare module 'vue' {
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
PlayBottom: typeof import('./src/components/common/PlayBottom.vue')['default']
PlayListsItem: typeof import('./src/components/common/PlayListsItem.vue')['default']
PlaylistType: typeof import('./src/components/PlaylistType.vue')['default']

View File

@@ -1,18 +1,19 @@
{
"name": "alger-music",
"version": "1.5.1",
"version": "2.1.0",
"description": "这是一个用于音乐播放的应用程序。",
"author": "Alger <algerkc@qq.com>",
"main": "app.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "cross-env NODE_ENV=production vite build",
"serve": "vite preview",
"start": "set NODE_ENV=development&&electron .",
"start": "cross-env NODE_ENV=development electron .",
"lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0",
"b:win:x64": "electron-builder --config ./build/win64.json",
"b:win:x86": "electron-builder --config ./build/win32.json",
"b:win:arm": "electron-builder --config ./build/winarm64.json"
"b:win:x64": "cross-env NODE_ENV=production electron-builder --config ./build/win64.json",
"b:win:x86": "cross-env NODE_ENV=production electron-builder --config ./build/win32.json",
"b:win:arm": "cross-env NODE_ENV=production electron-builder --config ./build/winarm64.json",
"b:mac": "cross-env NODE_ENV=production electron-builder --config ./build/mac.json"
},
"dependencies": {
"electron-store": "^8.1.0"
@@ -21,16 +22,16 @@
"@tailwindcss/postcss7-compat": "^2.2.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^4.2.3",
"@vue/compiler-sfc": "^3.3.4",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/runtime-core": "^3.3.4",
"@vueuse/core": "^10.7.1",
"@vueuse/electron": "^10.9.0",
"autoprefixer": "^9.8.6",
"axios": "^0.21.1",
"electron": "^30.0.0",
"electron-builder": "^24.13.0",
"@vitejs/plugin-vue": "^5.1.3",
"@vue/compiler-sfc": "^3.5.0",
"@vue/eslint-config-typescript": "^13.0.0",
"@vue/runtime-core": "^3.5.0",
"@vueuse/core": "^11.0.3",
"@vueuse/electron": "^11.0.3",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
"electron": "^32.0.1",
"electron-builder": "^25.0.5",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.0.0",
@@ -40,22 +41,23 @@
"eslint-plugin-vue": "^9.21.1",
"eslint-plugin-vue-scoped-css": "^2.7.2",
"lodash": "^4.17.21",
"naive-ui": "^2.38.2",
"postcss": "^7.0.36",
"prettier": "^3.2.5",
"naive-ui": "^2.39.0",
"postcss": "^8.4.44",
"prettier": "^3.3.3",
"remixicon": "^4.2.0",
"sass": "^1.35.2",
"sass": "^1.78.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.4",
"typescript": "^4.3.2",
"unplugin-auto-import": "^0.17.2",
"unplugin-vue-components": "^0.26.0",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.2",
"unplugin-vue-components": "^0.27.4",
"vfonts": "^0.1.0",
"vite": "^4.4.7",
"vite": "^5.4.3",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "1.0.0-beta.5",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vue-tsc": "^0.0.24",
"vuex": "^4.1.0"
"vite-plugin-vue-devtools": "7.4.0",
"vue": "^3.5.0",
"vue-router": "^4.4.3",
"vue-tsc": "^2.1.4",
"vuex": "^4.1.0",
"cross-env": "^7.0.3"
}
}

View File

@@ -1,3 +1,7 @@
body{
background-color: #000;
}
.n-popover:has(.music-play){
border-radius: 1.5rem !important;
}

BIN
public/icon.icns Normal file

Binary file not shown.

BIN
public/icon_16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -3,9 +3,7 @@
<audio id="MusicAudio" ref="audioRef" :src="playMusicUrl" :autoplay="play"></audio>
<n-config-provider :theme="darkTheme">
<n-dialog-provider>
<keep-alive>
<router-view></router-view>
</keep-alive>
<router-view></router-view>
</n-dialog-provider>
</n-config-provider>
</div>

View File

@@ -43,7 +43,7 @@ export const getRecommendMusic = (params: IRecommendMusicParams) => {
// 获取每日推荐
export const getDayRecommend = () => {
return request.get<IData<IDayRecommend>>('/recommend/songs');
return request.get<IData<IData<IDayRecommend>>>('/recommend/songs');
};
// 获取最新专辑推荐

View File

@@ -3,10 +3,13 @@ import { IMvItem, IMvUrlData } from '@/type/mv';
import request from '@/utils/request';
// 获取 mv 排行
export const getTopMv = (limit: number) => {
return request.get<IData<Array<IMvItem>>>('/top/mv', {
export const getTopMv = (limit = 30, offset = 0) => {
return request({
url: '/mv/all',
method: 'get',
params: {
limit,
offset,
},
});
};

View File

@@ -3,28 +3,47 @@
:show="show"
:height="isMobile ? '100vh' : '70vh'"
placement="bottom"
:drawer-style="{ backgroundColor: 'transparent' }"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
@mask-click="close"
>
<div class="music-page">
<i class="iconfont icon-icon_error music-close" @click="close"></i>
<div class="music-close">
<i class="icon ri-layout-column-line" @click="doubleDisply = !doubleDisply"></i>
<i class="icon iconfont icon-icon_error" @click="close"></i>
</div>
<div class="music-title text-el">{{ name }}</div>
<!-- 歌单歌曲列表 -->
<div :show="loading" class="music-list">
<n-scrollbar>
<n-spin :show="loading">
<div class="music-list-content">
<div
v-for="(item, index) in songList"
:key="item.id"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 50)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
<div class="music-list">
<n-scrollbar @scroll="handleScroll">
<div
v-loading="loading || !songList.length"
class="music-list-content"
:class="{ 'double-list': doubleDisply }"
>
<div
v-for="(item, index) in displayedSongs"
:key="item.id"
class="double-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 5)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
</n-spin>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
</div>
<play-bottom />
</n-scrollbar>
<!-- <n-virtual-list :item-size="42" :items="displayedSongs" item-resizable @scroll="handleScroll">
<template #default="{ item, index }">
<div :key="item.id" class="double-item">
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
</template>
</n-virtual-list>
<play-bottom /> -->
</div>
</div>
</n-drawer>
@@ -33,29 +52,35 @@
<script setup lang="ts">
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const loading = ref(true);
const store = useStore();
const props = defineProps<{
const {
songList,
loading = false,
listInfo,
} = defineProps<{
show: boolean;
name: string;
songList: any[];
loading?: boolean;
listInfo?: any;
}>();
const emit = defineEmits(['update:show']);
const emit = defineEmits(['update:show', 'update:loading']);
watch(
() => props.songList,
(val) => {
loading.value = !(val && val.length);
},
{ immediate: true },
);
const page = ref(0);
const pageSize = 20;
const total = ref(0);
const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]);
// 双排显示开关
const doubleDisply = ref(false);
const formatDetail = computed(() => (detail: any) => {
const song = {
@@ -70,13 +95,57 @@ const formatDetail = computed(() => (detail: any) => {
});
const handlePlay = () => {
const tracks = props.songList || [];
store.commit('setPlayList', tracks);
const tracks = songList || [];
store.commit(
'setPlayList',
tracks.map((item) => ({
...item,
picUrl: item.al.picUrl,
song: {
artists: item.ar,
},
})),
);
};
const close = () => {
emit('update:show', false);
};
const loadMoreSongs = async () => {
if (displayedSongs.value.length >= total.value) return;
isLoadingMore.value = true;
try {
const trackIds = listInfo.trackIds
.slice(page.value * pageSize, (page.value + 1) * pageSize)
.map((item: any) => item.id);
const reslist = await getMusicDetail(trackIds);
// displayedSongs.value = displayedSongs.value.concat(reslist.data.songs);
displayedSongs.value = JSON.parse(JSON.stringify([...displayedSongs.value, ...reslist.data.songs]));
page.value++;
} catch (error) {
console.error('error', error);
} finally {
isLoadingMore.value = false;
}
};
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop + clientHeight >= scrollHeight - 50 && !isLoadingMore.value) {
loadMoreSongs();
}
};
watch(
() => songList,
(newSongs) => {
displayedSongs.value = JSON.parse(JSON.stringify(newSongs));
total.value = listInfo ? listInfo.trackIds.length : displayedSongs.value.length;
},
{ deep: true, immediate: true },
);
</script>
<style scoped lang="scss">
@@ -85,16 +154,21 @@ const close = () => {
@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 text-3xl;
@apply absolute top-4 right-8 cursor-pointer text-white flex gap-2 items-center;
.icon {
@apply text-3xl;
}
}
&-list {
height: calc(100% - 60px);
&-content {
min-height: 400px;
}
@@ -106,4 +180,20 @@ const close = () => {
@apply px-4;
}
}
.loading-more {
@apply text-center text-white py-10;
}
.double-list {
@apply flex flex-wrap gap-5;
.double-item {
width: calc(50% - 10px);
}
.song-item {
background-color: #191919;
}
}
</style>

792
src/components/MvPlayer.vue Normal file
View File

@@ -0,0 +1,792 @@
<template>
<n-drawer :show="show" height="100vh" placement="bottom" :z-index="999999999">
<div class="mv-detail">
<div ref="videoContainerRef" class="video-container" :class="{ 'cursor-hidden': !showCursor }">
<video
ref="videoRef"
:src="mvUrl"
class="video-player"
@ended="handleEnded"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@play="isPlaying = true"
@pause="isPlaying = false"
@click="togglePlay"
></video>
<div v-if="autoPlayBlocked" class="play-hint" @click="togglePlay">
<n-button quaternary circle size="large">
<template #icon>
<n-icon size="48">
<i class="ri-play-circle-line"></i>
</n-icon>
</template>
</n-button>
</div>
<div class="custom-controls" :class="{ 'controls-hidden': !showControls }">
<div class="progress-bar custom-slider">
<n-slider
v-model:value="progress"
:min="0"
:max="100"
:tooltip="false"
:step="0.1"
@update:value="handleProgressChange"
>
<template #rail>
<div class="progress-rail">
<div class="progress-buffer" :style="{ width: `${bufferedProgress}%` }"></div>
</div>
</template>
</n-slider>
</div>
<div class="controls-main">
<div class="left-controls">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle :disabled="isPrevDisabled" @click="handlePrev">
<template #icon>
<n-icon size="24">
<n-spin v-if="prevLoading" size="small" />
<i v-else class="ri-skip-back-line"></i>
</n-icon>
</template>
</n-button>
</template>
上一个
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="togglePlay">
<template #icon>
<n-icon size="24">
<n-spin v-if="playLoading" size="small" />
<i v-else :class="isPlaying ? 'ri-pause-line' : 'ri-play-line'"></i>
</n-icon>
</template>
</n-button>
</template>
{{ isPlaying ? '暂停' : '播放' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="handleNext">
<template #icon>
<n-icon size="24">
<n-spin v-if="nextLoading" size="small" />
<i v-else class="ri-skip-forward-line"></i>
</n-icon>
</template>
</n-button>
</template>
下一个
</n-tooltip>
<div class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</div>
</div>
<div class="right-controls">
<div class="volume-control custom-slider">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleMute">
<template #icon>
<n-icon size="24">
<i :class="volume === 0 ? 'ri-volume-mute-line' : 'ri-volume-up-line'"></i>
</n-icon>
</template>
</n-button>
</template>
{{ volume === 0 ? '取消静音' : '静音' }}
</n-tooltip>
<n-slider v-model:value="volume" :min="0" :max="100" :tooltip="false" class="volume-slider" />
</div>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle class="play-mode-btn" @click="togglePlayMode">
<template #icon>
<n-icon size="24">
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
</n-icon>
</template>
</n-button>
</template>
{{ playMode === 'single' ? '单曲循环' : '列表循环' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleFullscreen">
<template #icon>
<n-icon size="24">
<i :class="isFullscreen ? 'ri-fullscreen-exit-line' : 'ri-fullscreen-line'"></i>
</n-icon>
</template>
</n-button>
</template>
{{ isFullscreen ? '退出全屏' : '全屏' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="handleClose">
<template #icon>
<n-icon size="24">
<i class="ri-close-line"></i>
</n-icon>
</template>
</n-button>
</template>
关闭
</n-tooltip>
</div>
</div>
</div>
<!-- 添加模式切换提示 -->
<transition name="fade">
<div v-if="showModeHint" class="mode-hint">
<n-icon size="48" class="mode-icon">
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
</n-icon>
<div class="mode-text">
{{ playMode === 'single' ? '单曲循环' : '自动播放下一个' }}
</div>
</div>
</transition>
</div>
<div class="mv-detail-title" :class="{ 'title-hidden': !showControls }">
<div class="title">
<n-ellipsis>{{ currentMv?.name }}</n-ellipsis>
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { NButton, NIcon, NSlider, NTooltip, useMessage } from 'naive-ui';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { getMvUrl } from '@/api/mv';
import { IMvItem } from '@/type/mv';
type PlayMode = 'single' | 'auto';
const PLAY_MODE = {
Single: 'single' as PlayMode,
Auto: 'auto' as PlayMode,
} as const;
const props = defineProps<{
show: boolean;
currentMv?: IMvItem;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'next', loading: (value: boolean) => void): void;
(e: 'prev', loading: (value: boolean) => void): void;
}>();
const store = useStore();
const mvUrl = ref<string>();
const playMode = ref<PlayMode>(PLAY_MODE.Auto);
const videoRef = ref<HTMLVideoElement>();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const progress = ref(0);
const bufferedProgress = ref(0);
const volume = ref(100);
const showControls = ref(true);
let controlsTimer: NodeJS.Timeout | null = null;
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
videoRef.value.play();
}
resetCursorTimer();
};
const toggleMute = () => {
if (!videoRef.value) return;
if (volume.value === 0) {
volume.value = 100;
} else {
volume.value = 0;
}
};
watch(volume, (newVolume) => {
if (videoRef.value) {
videoRef.value.volume = newVolume / 100;
}
});
const handleProgressChange = (value: number) => {
if (!videoRef.value || !duration.value) return;
const newTime = (value / 100) * duration.value;
videoRef.value.currentTime = newTime;
};
const handleTimeUpdate = () => {
if (!videoRef.value) return;
currentTime.value = videoRef.value.currentTime;
if (!isDragging.value) {
progress.value = (currentTime.value / duration.value) * 100;
}
if (videoRef.value.buffered.length > 0) {
bufferedProgress.value = (videoRef.value.buffered.end(0) / duration.value) * 100;
}
};
const handleLoadedMetadata = () => {
if (!videoRef.value) return;
duration.value = videoRef.value.duration;
};
const resetControlsTimer = () => {
if (controlsTimer) {
clearTimeout(controlsTimer);
}
showControls.value = true;
controlsTimer = setTimeout(() => {
if (isPlaying.value) {
showControls.value = false;
}
}, 3000);
};
const handleMouseMove = () => {
resetControlsTimer();
resetCursorTimer();
};
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove);
});
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove);
if (controlsTimer) {
clearTimeout(controlsTimer);
}
if (cursorTimer) {
clearTimeout(cursorTimer);
}
});
// 监听 currentMv 的变化
watch(
() => props.currentMv,
async (newMv) => {
if (newMv) {
await loadMvUrl(newMv);
}
},
);
const autoPlayBlocked = ref(false);
const playLoading = ref(false);
const loadMvUrl = async (mv: IMvItem) => {
playLoading.value = true;
autoPlayBlocked.value = false;
try {
const res = await getMvUrl(mv.id);
mvUrl.value = res.data.data.url;
await nextTick();
if (videoRef.value) {
try {
await videoRef.value.play();
} catch (error) {
console.warn('自动播放失败,可能需要用户交互:', error);
autoPlayBlocked.value = true;
}
}
} catch (error) {
console.error('加载MV地址失败:', error);
} finally {
playLoading.value = false;
}
};
const handleClose = () => {
emit('update:show', false);
if (store.state.playMusicUrl) {
store.commit('setIsPlay', true);
}
};
const handleEnded = () => {
if (playMode.value === PLAY_MODE.Single) {
// 单曲循环模式重新加载当前MV
if (props.currentMv) {
loadMvUrl(props.currentMv);
}
} else {
// 自动播放模式,触发下一个
emit('next');
}
};
const togglePlayMode = () => {
playMode.value = playMode.value === PLAY_MODE.Auto ? PLAY_MODE.Single : PLAY_MODE.Auto;
showModeHint.value = true;
setTimeout(() => {
showModeHint.value = false;
}, 1500);
};
const isDragging = ref(false);
// 添加全屏相关的状态和方法
const videoContainerRef = ref<HTMLElement>();
const isFullscreen = ref(false);
// 检查是否支持全屏API
const checkFullscreenAPI = () => {
const doc = document as any;
return {
requestFullscreen:
videoContainerRef.value?.requestFullscreen ||
videoContainerRef.value?.webkitRequestFullscreen ||
videoContainerRef.value?.mozRequestFullScreen ||
videoContainerRef.value?.msRequestFullscreen,
exitFullscreen: doc.exitFullscreen || doc.webkitExitFullscreen || doc.mozCancelFullScreen || doc.msExitFullscreen,
fullscreenElement:
doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement,
fullscreenEnabled:
doc.fullscreenEnabled || doc.webkitFullscreenEnabled || doc.mozFullScreenEnabled || doc.msFullscreenEnabled,
};
};
// 切换全屏状态
const toggleFullscreen = async () => {
const api = checkFullscreenAPI();
if (!api.fullscreenEnabled) {
console.warn('全屏API不可用');
return;
}
try {
if (!api.fullscreenElement) {
await videoContainerRef.value?.requestFullscreen();
isFullscreen.value = true;
} else {
await document.exitFullscreen();
isFullscreen.value = false;
}
} catch (error) {
console.error('切换全屏失败:', error);
}
};
// 监听全屏状态变化
const handleFullscreenChange = () => {
const api = checkFullscreenAPI();
isFullscreen.value = !!api.fullscreenElement;
};
// 在组件挂载时添加全屏变化监听
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
});
// 在组件卸载时移除监听
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
});
// 添加键盘快捷键支持
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
}
};
onMounted(() => {
// 添加到现有的 onMounted 中
document.addEventListener('keydown', handleKeyPress);
});
onUnmounted(() => {
// 添加到现有的 onUnmounted 中
document.removeEventListener('keydown', handleKeyPress);
});
// 在 setup 中初始化 message
const message = useMessage();
// 添加提示状态
const showModeHint = ref(false);
// 添加加载状态
const prevLoading = ref(false);
const nextLoading = ref(false);
// 添加处理函数
const handlePrev = () => {
prevLoading.value = true;
emit('prev', (value: boolean) => {
prevLoading.value = value;
});
};
const handleNext = () => {
nextLoading.value = true;
emit('next', (value: boolean) => {
nextLoading.value = value;
});
};
// 添加鼠标显示状态
const showCursor = ref(true);
let cursorTimer: NodeJS.Timeout | null = null;
// 添加重置鼠标计时器的函数
const resetCursorTimer = () => {
if (cursorTimer) {
clearTimeout(cursorTimer);
}
showCursor.value = true;
if (isPlaying.value && !showControls.value) {
cursorTimer = setTimeout(() => {
showCursor.value = false;
}, 3000);
}
};
// 监听播放状态变化
watch(isPlaying, (newValue) => {
if (!newValue) {
showCursor.value = true;
if (cursorTimer) {
clearTimeout(cursorTimer);
}
} else {
resetCursorTimer();
}
});
// 添加控制栏状态监听
watch(showControls, (newValue) => {
if (newValue) {
showCursor.value = true;
if (cursorTimer) {
clearTimeout(cursorTimer);
}
} else {
resetCursorTimer();
}
});
</script>
<style scoped lang="scss">
.mv-detail {
@apply w-full h-full bg-black relative;
.video-container {
@apply w-full h-full relative;
transition: cursor 0.3s ease;
&.cursor-hidden {
* {
cursor: none !important;
}
// 控制栏区域保持鼠标可见
.custom-controls {
* {
cursor: default !important;
}
.n-button {
cursor: pointer !important;
}
.n-slider {
cursor: pointer !important;
}
}
}
&:fullscreen,
&:-webkit-full-screen,
&:-moz-full-screen,
&:-ms-fullscreen {
background: black;
width: 100vw;
height: 100vh;
// 确保全屏时标题栏正确显示
.mv-detail-title {
@apply px-8 py-6;
.title {
@apply text-xl;
}
}
// 确保全屏时控制栏正确显示
.custom-controls {
padding: 20px 24px;
}
}
&::after {
content: '';
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
pointer-events: none;
}
&:active::after {
@apply opacity-10;
}
video {
@apply w-full h-full;
}
.custom-controls {
@apply absolute bottom-0 left-0 w-full transition-opacity duration-300 ease-in-out;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
padding: 16px 20px;
&.controls-hidden {
opacity: 0;
pointer-events: none;
}
.progress-bar {
@apply mb-4;
.progress-rail {
@apply relative w-full h-full;
.progress-buffer {
@apply absolute h-full bg-gray-600 rounded-full;
transition: width 0.2s ease;
}
}
}
.controls-main {
@apply flex justify-between items-center;
.left-controls,
.right-controls {
@apply flex items-center gap-4;
}
.time-display {
@apply text-sm text-white ml-2;
}
.volume-control {
@apply flex items-center gap-2;
.volume-slider {
width: 80px;
}
}
.n-button {
@apply text-white;
&:hover {
@apply text-green-400;
}
}
}
}
.play-hint {
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 cursor-pointer;
z-index: 10;
.n-button {
@apply text-white opacity-80 transform transition-all duration-300;
&:hover {
@apply opacity-100 scale-110;
}
}
}
}
.mv-detail-title {
@apply absolute w-full left-0 top-0 px-6 py-4 transition-opacity duration-300 z-50;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);
&.title-hidden {
opacity: 0;
}
.title {
@apply text-white text-lg font-medium;
max-width: 80%;
}
}
}
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: rgba(255, 255, 255, 0.2);
--n-fill-color: var(--primary-color);
--n-handle-size: 12px;
--n-handle-color: var(--primary-color);
&:hover {
--n-rail-height: 6px;
--n-handle-size: 14px;
}
.n-slider-rail {
@apply overflow-hidden;
}
.n-slider-handle {
@apply transition-opacity duration-200;
opacity: 0;
}
&:hover .n-slider-handle {
opacity: 1;
}
}
}
:root {
--primary-color: #18a058;
}
// 添加模式提示样式
.mode-hint {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2;
@apply flex flex-col items-center justify-center;
@apply bg-black bg-opacity-70 rounded-lg p-4;
z-index: 20;
.mode-icon {
@apply text-white mb-2;
}
.mode-text {
@apply text-white text-sm;
}
}
// 添加过渡动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// 添加 tooltip 样式
:deep(.n-tooltip) {
padding: 4px 8px;
font-size: 12px;
}
// 调左侧控制按钮的样式
.left-controls {
@apply flex items-center gap-2;
.time-display {
@apply text-sm text-white ml-4; // 增加时间显示的左边距
}
}
// 可以添加按钮禁用状态的样式
:deep(.n-button--disabled) {
opacity: 0.5;
cursor: not-allowed;
}
// 添加加载动画样式
:deep(.n-spin) {
.n-spin-body {
@apply text-white;
width: 20px;
height: 20px;
}
}
// 添加视频播放器样式
.video-player {
@apply w-full h-full cursor-pointer;
}
// 添加点击反馈效果
.video-container {
&::after {
content: '';
@apply absolute inset-0 bg-black opacity-0 transition-opacity duration-200;
pointer-events: none;
}
&:active::after {
@apply opacity-10;
}
}
// 添加鼠标隐藏样式
.video-container {
@apply w-full h-full relative;
transition: cursor 0.3s ease;
&.cursor-hidden {
* {
cursor: none !important;
}
// 控制栏区域保持鼠标可见
.custom-controls {
* {
cursor: default !important;
}
.n-button {
cursor: pointer !important;
}
.n-slider {
cursor: pointer !important;
}
}
}
}
</style>

View File

@@ -4,6 +4,7 @@
<div class="recommend-singer">
<div class="recommend-singer-list">
<div
v-if="dayRecommendData"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(0, 100)"
@@ -27,7 +28,7 @@
</div>
</div>
<div
v-for="(item, index) in hotSingerData?.artists.slice(0, 4)"
v-for="(item, index) in hotSingerData?.artists"
:key="item.id"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
@@ -59,6 +60,7 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getDayRecommend, getHotSinger } from '@/api/home';
import router from '@/router';
@@ -66,34 +68,42 @@ import { IDayRecommend } from '@/type/day_recommend';
import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
const store = useStore();
// 歌手信息
const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
// // 加载推荐歌手
// const loadSingerList = async () => {
// const { data } = await getHotSinger({ offset: 0, limit: 5 });
// hotSingerData.value = data;
// };
// const loadDayRecommend = async () => {
// const { data } = await getDayRecommend();
// dayRecommendData.value = data.data;
// };
// 页面初始化
onMounted(async () => {
await loadData();
});
const loadData = async () => {
try {
const [{ data: singerData }, { data: dayRecommend }] = await Promise.all([
getHotSinger({ offset: 0, limit: 5 }),
getDayRecommend(),
]);
// 第一个请求:获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
// 第二个请求:获取每日推荐
try {
const {
data: { data: dayRecommend },
} = await getDayRecommend();
console.log('dayRecommend', dayRecommend);
// 处理数据
if (dayRecommend) {
singerData.artists = singerData.artists.slice(0, 4);
}
dayRecommendData.value = dayRecommend;
} catch (error) {
console.error('error', error);
}
hotSingerData.value = singerData;
dayRecommendData.value = dayRecommend.data;
} catch (error) {
console.error('error', error);
}
});
};
const toSearchSinger = (keyword: string) => {
router.push({
@@ -103,6 +113,13 @@ const toSearchSinger = (keyword: string) => {
},
});
};
// 监听登录状态
watchEffect(() => {
if (store.state.user) {
loadData();
}
});
</script>
<style lang="scss" scoped>

View File

@@ -1,7 +1,12 @@
<template>
<div class="recommend-music">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
<div v-show="recommendMusic?.result" class="recommend-music-list" :class="setAnimationClass('animate__bounceInUp')">
<div
v-show="recommendMusic?.result"
v-loading="loading"
class="recommend-music-list"
:class="setAnimationClass('animate__bounceInUp')"
>
<!-- 推荐音乐列表 -->
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
<div :class="setAnimationClass('animate__bounceInUp')" :style="setAnimationDelay(index, 100)">
@@ -24,11 +29,14 @@ import SongItem from './common/SongItem.vue';
const store = useStore();
// 推荐歌曲
const recommendMusic = ref<IRecommendMusic>();
const loading = ref(false);
// 加载推荐歌曲
const loadRecommendMusic = async () => {
loading.value = true;
const { data } = await getRecommendMusic({ limit: 10 });
recommendMusic.value = data;
loading.value = false;
};
// 页面初始化

View File

@@ -16,6 +16,7 @@
v-model:show="showPop"
:name="item.name"
:song-list="songList"
:list-info="listInfo"
/>
<PlayVideo v-if="item.type === 'mv'" v-model:show="showPop" :title="item.name" :url="url" />
@@ -42,8 +43,10 @@ const url = ref('');
const songList = ref<any[]>([]);
const showPop = ref(false);
const listInfo = ref<any>(null);
const handleClick = async () => {
listInfo.value = null;
if (props.item.type === '专辑') {
showPop.value = true;
const res = await getAlbum(props.item.id);
@@ -57,6 +60,7 @@ const handleClick = async () => {
showPop.value = true;
const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks;
listInfo.value = res.data.playlist;
}
if (props.item.type === 'mv') {

View File

@@ -1,14 +1,24 @@
<template>
<div class="song-item" :class="{ 'song-mini': mini }">
<n-image v-if="item.picUrl" :src="getImgUrl(item.picUrl, '40y40')" class="song-item-img" lazy preview-disabled />
<n-image
v-if="item.picUrl"
ref="songImg"
:src="getImgUrl(item.picUrl, '40y40')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous',
}"
@load="imageLoad"
/>
<div class="song-item-content">
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span v-for="(artists, artistsindex) in item.song.artists" :key="artistsindex"
>{{ artists.name }}{{ artistsindex < item.song.artists.length - 1 ? ' / ' : '' }}</span
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
>{{ artists.name }}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
@@ -30,10 +40,12 @@
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const props = withDefaults(
defineProps<{
@@ -60,9 +72,27 @@ const isPlaying = computed(() => {
const emits = defineEmits(['play']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
if (!songImageRef.value) {
return;
}
const { backgroundColor } = await getImageBackground(
(songImageRef.value as any).imageRef as unknown as HTMLImageElement,
);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
if (playMusic.value.id === item.id) {
if (play.value) {
store.commit('setPlayMusic', false);
} else {
store.commit('setPlayMusic', true);
}
return;
}
await store.commit('setPlay', item);

7
src/directive/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { vLoading } from './loading/index';
const directives = {
loading: vLoading,
};
export default directives;

View File

@@ -0,0 +1,40 @@
import { createVNode, render, VNode } from 'vue';
import Loading from './index.vue';
const vnode: VNode = createVNode(Loading) as VNode;
export const vLoading = {
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
mounted: (el: HTMLElement, binding: any) => {
render(vnode, el);
},
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
updated: (el: HTMLElement, binding: any) => {
if (binding.value) {
vnode?.component?.exposed.show();
} else {
vnode?.component?.exposed.hide();
}
// 动态添加删除自定义class: loading-parent
formatterClass(el, binding);
},
// 绑定元素的父组件卸载后调用
unmounted: () => {
vnode?.component?.exposed.hide();
},
};
function formatterClass(el: HTMLElement, binding: any) {
const classStr = el.getAttribute('class');
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
if (binding.value) {
if (tagetClass === -1) {
el.setAttribute('class', `${classStr} loading-parent`);
}
} else if (tagetClass > -1) {
const classArray: Array<string> = classStr?.split('') as string[];
classArray.splice(tagetClass - 1, tagetClass + 15);
el.setAttribute('class', classArray?.join(''));
}
}

View File

@@ -0,0 +1,92 @@
<!-- -->
<template>
<div v-if="isShow" class="loading-box">
<div class="mask" :style="{ background: maskBackground }"></div>
<div class="loading-content-box">
<n-spin size="small" />
<div :style="{ color: textColor }" class="tip">{{ tip }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NSpin } from 'naive-ui';
import { ref } from 'vue';
defineProps({
tip: {
type: String,
default() {
return '加载中...';
},
},
maskBackground: {
type: String,
default() {
return 'rgba(0, 0, 0, 0.8)';
},
},
loadingColor: {
type: String,
default() {
return 'rgba(255, 255, 255, 1)';
},
},
textColor: {
type: String,
default() {
return 'rgba(255, 255, 255, 1)';
},
},
});
const isShow = ref(false);
const show = () => {
isShow.value = true;
};
const hide = () => {
isShow.value = false;
};
defineExpose({
show,
hide,
isShow,
});
</script>
<style lang="scss" scoped>
.loading-box {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 9999;
.n-spin {
// color: #ccc;
}
.mask {
width: 100%;
height: 100%;
}
.loading-content-box {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.tip {
font-size: 14px;
margin-top: 8px;
}
}
</style>

183
src/hooks/IndexDBHook.ts Normal file
View File

@@ -0,0 +1,183 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ref } from 'vue';
// 创建一个使用 IndexedDB 的组合函数
const useIndexedDB = () => {
const db = ref<IDBDatabase | null>(null); // 数据库引用
// 打开数据库并创建表
const initDB = (dbName: string, version: number, stores: { name: string; keyPath?: string }[]) => {
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(dbName, version); // 打开数据库请求
request.onupgradeneeded = (event: any) => {
const db = event.target.result; // 获取数据库实例
stores.forEach((store) => {
if (!db.objectStoreNames.contains(store.name)) {
// 确保对象存储(表)创建
db.createObjectStore(store.name, {
keyPath: store.keyPath || 'id',
autoIncrement: true,
});
}
});
};
request.onsuccess = (event: any) => {
db.value = event.target.result; // 保存数据库实例
resolve(); // 成功时解析 Promise
};
request.onerror = (event: any) => {
reject(event.target.error); // 失败时拒绝 Promise
};
});
};
// 通用新增数据
const addData = (storeName: string, value: any) => {
return new Promise<void>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化'); // 检查数据库是否已初始化
const tx = db.value.transaction(storeName, 'readwrite'); // 创建事务
const store = tx.objectStore(storeName); // 获取对象存储
const request = store.add(value); // 添加数据请求
request.onsuccess = () => {
console.log('成功'); // 成功时输出
resolve(); // 解析 Promise
};
request.onerror = (event) => {
console.error('新增失败:', (event.target as IDBRequest).error); // 输出错误
reject((event.target as IDBRequest).error); // 拒绝 Promise
};
});
};
// 通用保存数据(新增或更新)
const saveData = (storeName: string, value: any) => {
return new Promise<void>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.put(value);
request.onsuccess = () => {
console.log('成功');
resolve();
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 通用获取数据
const getData = (storeName: string, key: string | number) => {
return new Promise<any>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.get(key);
request.onsuccess = (event) => {
if (event.target) {
resolve((event.target as IDBRequest).result);
} else {
reject('事件目标为空');
}
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 删除数据
const deleteData = (storeName: string, key: string | number) => {
return new Promise<void>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => {
console.log('删除成功');
resolve();
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 查询所有数据
const getAllData = (storeName: string) => {
return new Promise<any[]>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onsuccess = (event) => {
if (event.target) {
resolve((event.target as IDBRequest).result);
} else {
reject('事件目标为空');
}
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 分页查询数据
const getDataWithPagination = (storeName: string, page: number, pageSize: number) => {
return new Promise<any[]>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.openCursor(); // 打开游标请求
const results: any[] = []; // 存储结果的数组
let index = 0; // 当前索引
const skip = (page - 1) * pageSize; // 计算跳过的数量
request.onsuccess = (event: any) => {
const cursor = event.target.result; // 获取游标
if (!cursor) {
resolve(results); // 如果没有更多数据,解析结果
return;
}
if (index >= skip && results.length < pageSize) {
results.push(cursor.value); // 添加当前游标值到结果
}
index++; // 增加索引
cursor.continue(); // 继续游标
};
request.onerror = (event: any) => {
reject(event.target.error);
};
});
};
return {
initDB,
addData,
saveData,
getData,
deleteData,
getAllData,
getDataWithPagination,
};
};
export default useIndexedDB;

View File

@@ -1,147 +1,177 @@
import { computed, ref } from 'vue';
import { getMusicLrc } from '@/api/music';
import store from '@/store';
import { ILyric } from '@/type/lyric';
import type { ILyricText, SongResult } from '@/type/music';
interface ILrcData {
text: string;
trText: string;
}
const windowData = window as any;
export const lrcData = ref<ILyric>();
export const newLrcIndex = ref<number>(0);
export const lrcArray = ref<Array<ILrcData>>([]);
export const lrcTimeArray = ref<Array<Number>>([]);
export const isElectron = computed(() => !!windowData.electronAPI);
export const parseTime = (timeString: string) => {
const [minutes, seconds] = timeString.split(':');
return Number(minutes) * 60 + Number(seconds);
};
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
export const lrcTimeArray = ref<number[]>([]); // 歌词时间数组
export const nowTime = ref(0); // 当前播放时间
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); // 当前播放歌曲
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
function parseLyricLine(lyricLine: string) {
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
const time = parseTime(timeText);
const text = lyricLine.replace(LRC_REGEX, '').trim();
return { time, text };
}
interface ILyricText {
text: string;
trText: string;
}
function parseLyrics(lyricsString: string) {
const lines = lyricsString.split('\n');
const lyrics: Array<ILyricText> = [];
const times: number[] = [];
lines.forEach((line) => {
const { time, text } = parseLyricLine(line);
times.push(time);
lyrics.push({ text, trText: '' });
});
return { lyrics, times };
}
export const loadLrc = async (playMusicId: number): Promise<void> => {
try {
const { data } = await getMusicLrc(playMusicId);
const { lyrics, times } = parseLyrics(data.lrc.lyric);
let tlyric: {
[key: string]: string;
} = {};
if (data.tlyric.lyric) {
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
tlyric = tLyrics.reduce((acc: any, cur, index) => {
acc[tTimes[index]] = cur.text;
return acc;
}, {});
}
if (Object.keys(tlyric).length) {
lyrics.forEach((item, index) => {
item.trText = item.text ? tlyric[times[index].toString()] : '';
});
}
lrcTimeArray.value = times;
lrcArray.value = lyrics;
} catch (err) {
console.error('err', err);
}
};
// 歌词矫正时间Correction time
const correctionTime = ref(0.4);
watch(
() => store.state.playMusic,
() => {
nextTick(() => {
lrcArray.value = playMusic.value.lyric?.lrcArray || [];
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
});
},
{
deep: true,
},
);
const isPlaying = computed(() => store.state.play as boolean);
// 增加矫正时间
export const addCorrectionTime = (time: number) => {
correctionTime.value += time;
};
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
// 减少矫正时间
export const reduceCorrectionTime = (time: number) => {
correctionTime.value -= time;
};
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
export const isCurrentLrc = (index: number, time: number) => {
const currentTime = Number(lrcTimeArray.value[index]);
const nextTime = Number(lrcTimeArray.value[index + 1]);
// 获取当前播放歌词
export const isCurrentLrc = (index: number, time: number): boolean => {
const currentTime = lrcTimeArray.value[index];
const nextTime = lrcTimeArray.value[index + 1];
const nowTime = time + correctionTime.value;
const isTrue = nowTime > currentTime && nowTime < nextTime;
if (isTrue) {
newLrcIndex.value = index;
}
return isTrue;
};
export const nowTime = ref(0);
export const allTime = ref(0);
export const nowIndex = ref(0);
export const getLrcIndex = (time: number) => {
// 获取当前播放歌词INDEX
export const getLrcIndex = (time: number): number => {
for (let i = 0; i < lrcTimeArray.value.length; i++) {
if (isCurrentLrc(i, time)) {
nowIndex.value = i || nowIndex.value;
nowIndex.value = i;
return i;
}
}
return nowIndex.value;
};
// 设置当前播放时间
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index] as number;
audio.play();
// 获取当前播放歌词进度
const currentLrcTiming = computed(() => {
const start = lrcTimeArray.value[nowIndex.value] || 0;
const end = lrcTimeArray.value[nowIndex.value + 1] || start + 1;
return { start, end };
});
// 获取歌词样式
export const getLrcStyle = (index: number) => {
if (index === nowIndex.value) {
return {
backgroundImage: `linear-gradient(to right, #ffffff ${currentLrcProgress.value}%, #ffffff8a ${currentLrcProgress.value}%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
transition: 'background-image 0.1s linear',
};
}
return {};
};
// 计算这个歌词的播放时间
const getLrcTime = (index: number) => {
return Number(lrcTimeArray.value[index]);
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 { start, end } = currentLrcTiming.value;
const duration = end - start;
const elapsed = audio.value.currentTime - start;
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
animationFrameId = requestAnimationFrame(updateProgress);
};
const startProgressAnimation = () => {
if (!animationFrameId && isPlaying.value) {
updateProgress();
}
};
const stopProgressAnimation = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
};
watch(isPlaying, (newIsPlaying) => {
if (newIsPlaying) {
startProgressAnimation();
} else {
stopProgressAnimation();
}
});
onMounted(() => {
if (isPlaying.value) {
startProgressAnimation();
}
});
onUnmounted(() => {
stopProgressAnimation();
});
return {
currentLrcProgress,
getLrcStyle,
};
};
// 设置当前播放时间
export const setAudioTime = (index: number, audio: HTMLAudioElement) => {
audio.currentTime = lrcTimeArray.value[index];
audio.play();
};
// 获取当前播放的歌词
export const getCurrentLrc = () => {
const index = getLrcIndex(nowTime.value);
const currentLrc = lrcArray.value[index];
const nextLrc = lrcArray.value[index + 1];
return { currentLrc, nextLrc };
return {
currentLrc: lrcArray.value[index],
nextLrc: lrcArray.value[index + 1],
};
};
// 获取一句歌词播放时间是 几秒到几秒
export const getLrcTimeRange = (index: number) => {
const currentTime = Number(lrcTimeArray.value[index]);
const nextTime = Number(lrcTimeArray.value[index + 1]);
return { currentTime, nextTime };
};
export const getLrcTimeRange = (index: number) => ({
currentTime: lrcTimeArray.value[index],
nextTime: lrcTimeArray.value[index + 1],
});
export const sendLyricToWin = (isPlay: boolean = true) => {
if (!isElectron.value) return;
try {
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
let lyricWinData = null;
if (lrcArray.value.length > 0) {
const nowIndex = getLrcIndex(nowTime.value);
const { currentLrc, nextLrc } = getCurrentLrc();
const { currentTime, nextTime } = getLrcTimeRange(nowIndex);
lyricWinData = {
// 设置lyricWinData 获取 当前播放的两句歌词 和歌词时间
const lyricWinData = {
currentLrc,
nextLrc,
currentTime,
@@ -151,20 +181,18 @@ export const sendLyricToWin = (isPlay: boolean = true) => {
lrcArray: lrcArray.value,
nowTime: nowTime.value,
allTime: allTime.value,
startCurrentTime: getLrcTime(nowIndex),
startCurrentTime: lrcTimeArray.value[nowIndex],
isPlay,
};
const windowData = window as any;
windowData.electronAPI.sendLyric(JSON.stringify(lyricWinData));
}
} catch (error) {
console.error('error', error);
console.error('Error sending lyric to window:', error);
}
};
export const openLyric = () => {
const windowData = window as any;
if (!isElectron.value) return;
windowData.electronAPI.openLyric();
sendLyricToWin();
};

177
src/hooks/MusicListHook.ts Normal file
View File

@@ -0,0 +1,177 @@
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import type { ILyric, ILyricText, SongResult } from '@/type/music';
import { getImgUrl, getMusicProxyUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
const musicHistory = useMusicHistory();
// 获取歌曲url
const getSongUrl = async (id: number) => {
const { data } = await getMusicUrl(id);
let url = '';
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(id);
url = res.data.data.url;
}
} catch (error) {
console.error('error', error);
}
url = url || data.data[0].url;
return getMusicProxyUrl(url);
};
const getSongDetail = async (playMusic: SongResult) => {
if (playMusic.playMusicUrl) {
return playMusic;
}
playMusic.playLoading = true;
const playMusicUrl = await getSongUrl(playMusic.id);
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
playMusic.playLoading = false;
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor };
};
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
export const useMusicListHook = () => {
const handlePlayMusic = async (state: any, playMusic: SongResult) => {
const updatedPlayMusic = await getSongDetail(playMusic);
state.playMusic = updatedPlayMusic;
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
state.play = true;
// 设置网页标题
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
loadLrcAsync(state, updatedPlayMusic.id);
musicHistory.addMusic(state.playMusic);
const playListIndex = state.playList.findIndex((item: SongResult) => item.id === playMusic.id);
state.playListIndex = playListIndex;
// 请求后续五首歌曲的详情
fetchSongs(state, playListIndex + 1, playListIndex + 6);
};
// 用于预加载下一首歌曲的 MP3 数据
const preloadNextSong = (nextSongUrl: string) => {
const audio = new Audio(nextSongUrl);
audio.preload = 'auto'; // 设置预加载
audio.load(); // 手动加载
};
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
const songs = state.playList.slice(Math.max(0, startIndex), Math.min(endIndex, state.playList.length));
const detailedSongs = await Promise.all(
songs.map(async (song: SongResult) => {
// 如果歌曲详情已经存在,就不重复请求
if (!song.playMusicUrl) {
return await getSongDetail(song);
}
return song;
}),
);
// 加载下一首的歌词
const nextSong = detailedSongs[0];
if (!(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
nextSong.lyric = await loadLrc(nextSong.id);
}
// 更新播放列表中的歌曲详情
detailedSongs.forEach((song, index) => {
state.playList[startIndex + index] = song;
});
preloadNextSong(nextSong.playMusicUrl);
};
const nextPlay = async (state: any) => {
if (state.playList.length === 0) {
state.play = true;
return;
}
const playListIndex = (state.playListIndex + 1) % state.playList.length;
await handlePlayMusic(state, state.playList[playListIndex]);
};
const prevPlay = async (state: any) => {
if (state.playList.length === 0) {
state.play = true;
return;
}
const playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
await handlePlayMusic(state, state.playList[playListIndex]);
await fetchSongs(state, playListIndex - 5, playListIndex);
};
const parseTime = (timeString: string): number => {
const [minutes, seconds] = timeString.split(':');
return Number(minutes) * 60 + Number(seconds);
};
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
const time = parseTime(timeText);
const text = lyricLine.replace(LRC_REGEX, '').trim();
return { time, text };
};
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
const lines = lyricsString.split('\n');
const lyrics: ILyricText[] = [];
const times: number[] = [];
lines.forEach((line) => {
const { time, text } = parseLyricLine(line);
times.push(time);
lyrics.push({ text, trText: '' });
});
return { lyrics, times };
};
const loadLrc = async (playMusicId: number): Promise<ILyric> => {
try {
const { data } = await getMusicLrc(playMusicId);
const { lyrics, times } = parseLyrics(data.lrc.lyric);
const tlyric: Record<string, string> = {};
if (data.tlyric.lyric) {
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
tLyrics.forEach((lyric, index) => {
tlyric[tTimes[index].toString()] = lyric.text;
});
}
lyrics.forEach((item, index) => {
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
});
return {
lrcTimeArray: times,
lrcArray: lyrics,
};
} catch (err) {
console.error('Error loading lyrics:', err);
return {
lrcTimeArray: [],
lrcArray: [],
};
}
};
// 异步加载歌词的方法
const loadLrcAsync = async (state: any, playMusicId: number) => {
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
return;
}
const lyrics = await loadLrc(playMusicId);
state.playMusic.lyric = lyrics;
};
return {
handlePlayMusic,
nextPlay,
prevPlay,
};
};

View File

@@ -1,6 +1,6 @@
<template>
<div class="layout-page">
<div class="layout-main">
<div class="layout-main" :style="{ background: backgroundColor }">
<title-bar v-if="isElectron" />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
<!-- 侧边菜单栏 -->
@@ -9,7 +9,7 @@
<!-- 搜索栏 -->
<search-bar />
<!-- 主页面路由 -->
<div class="main-content bg-black" :native-scrollbar="false">
<div class="main-content" :native-scrollbar="false">
<n-message-provider>
<router-view
v-slot="{ Component }"
@@ -37,6 +37,7 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import PlayBottom from '@/components/common/PlayBottom.vue';
import { isElectron } from '@/hooks/MusicHook';
import homeRouter from '@/router/home';
import { isMobile } from '@/utils';
@@ -69,11 +70,18 @@ const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const windowData = window as any;
const isElectron = computed(() => {
return !!windowData.electronAPI;
});
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(
@@ -95,14 +103,6 @@ onMounted(() => {
default:
}
};
// 按下键盘按钮监听
document.onkeydown = (e) => {
switch (e.code) {
case 'Space':
return false;
default:
}
};
});
const audioPlay = () => {
@@ -130,11 +130,11 @@ const playMusicEvent = async () => {
.layout-page {
width: 100vw;
height: 100vh;
@apply flex justify-center items-center overflow-hidden;
@apply flex justify-center items-center overflow-hidden bg-black;
}
.layout-main {
@apply bg-black text-white shadow-xl flex flex-col relative;
@apply text-white shadow-xl flex flex-col relative transition-all;
height: 100%;
width: 100%;
overflow: hidden;

View File

@@ -1,66 +1,74 @@
<template>
<n-drawer :show="musicFull" height="100vh" placement="bottom" :drawer-style="{ backgroundColor: 'transparent' }">
<n-drawer
:show="musicFull"
height="100vh"
placement="bottom"
:style="{ background: currentBackground || background }"
>
<div id="drawer-target">
<div
class="drawer-back"
:class="{ paused: !isPlaying }"
:style="{ backgroundImage: `url(${getImgUrl(playMusic?.picUrl, '300y300')})` }"
></div>
<div class="drawer-back"></div>
<div class="music-img">
<n-image ref="PicImgRef" :src="getImgUrl(playMusic?.picUrl, '300y300')" class="img" lazy preview-disabled />
<div>
<div class="music-content-name">{{ playMusic.name }}</div>
<div class="music-content-singer">
<span v-for="(item, index) in playMusic.song.artists" :key="index">
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
</span>
</div>
</div>
</div>
<div class="music-content">
<div class="music-content-name">{{ playMusic.song.name }}</div>
<div class="music-content-singer">
<span v-for="(item, index) in playMusic.song.artists" :key="index">
{{ item.name }}{{ index < playMusic.song.artists.length - 1 ? ' / ' : '' }}
</span>
</div>
<n-layout
ref="lrcSider"
class="music-lrc"
style="height: 55vh"
style="height: 60vh"
:native-scrollbar="false"
@mouseover="mouseOverLayout"
@mouseleave="mouseLeaveLayout"
>
<template v-for="(item, index) in lrcArray" :key="index">
<div ref="lrcContainer">
<div
v-for="(item, index) in lrcArray"
:id="`music-lrc-text-${index}`"
:key="index"
class="music-lrc-text"
:class="{ 'now-text': isCurrentLrc(index, nowTime) }"
:class="{ 'now-text': index === nowIndex }"
@click="setAudioTime(index, audio)"
>
<div>{{ item.text }}</div>
<span :style="getLrcStyle(index)">{{ item.text }}</span>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
</div>
</template>
</div>
</n-layout>
<!-- 时间矫正 -->
<div class="music-content-time">
<!-- <div class="music-content-time">
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
<n-button @click="addCorrectionTime(0.2)">+</n-button>
</div>
</div> -->
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { useDebounceFn } from '@vueuse/core';
import { onBeforeUnmount, ref, watch } from 'vue';
import {
addCorrectionTime,
isCurrentLrc,
lrcArray,
newLrcIndex,
nowTime,
reduceCorrectionTime,
setAudioTime,
} from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { lrcArray, nowIndex, playMusic, setAudioTime, useLyricProgress } from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
const store = useStore();
// 定义 refs
const lrcSider = ref<any>(null);
const isMouse = ref(false);
const lrcContainer = ref<HTMLElement | null>(null);
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
// 初始化 textColors
const textColors = ref(getTextColors());
const props = defineProps({
musicFull: {
@@ -71,30 +79,118 @@ const props = defineProps({
type: HTMLAudioElement,
default: null,
},
background: {
type: String,
default: '',
},
});
// 播放的音乐信息
const playMusic = computed(() => store.state.playMusic as SongResult);
const isPlaying = computed(() => store.state.play as boolean);
// 获取歌词滚动dom
const lrcSider = ref<any>(null);
const isMouse = ref(false);
// 歌词滚动方法
const lrcScroll = () => {
if (props.musicFull && !isMouse.value) {
const top = newLrcIndex.value * 60 - 225;
lrcSider.value.scrollTo({ top, behavior: 'smooth' });
const lrcScroll = (behavior = 'smooth') => {
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
if (props.musicFull && !isMouse.value && nowEl && lrcContainer.value) {
const containerRect = lrcContainer.value.getBoundingClientRect();
const nowElRect = nowEl.getBoundingClientRect();
const relativeTop = nowElRect.top - containerRect.top;
const scrollTop = relativeTop - lrcSider.value.$el.getBoundingClientRect().height / 2;
lrcSider.value.scrollTo({ top: scrollTop, behavior });
}
};
const debouncedLrcScroll = useDebounceFn(lrcScroll, 200);
const mouseOverLayout = () => {
isMouse.value = true;
};
const mouseLeaveLayout = () => {
setTimeout(() => {
isMouse.value = false;
}, 3000);
lrcScroll();
}, 2000);
};
watch(nowIndex, () => {
debouncedLrcScroll();
});
watch(
() => props.musicFull,
() => {
if (props.musicFull) {
nextTick(() => {
lrcScroll('instant');
});
}
},
);
// 监听背景变化
watch(
() => props.background,
(newBg) => {
if (!newBg) {
textColors.value = getTextColors();
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(false));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
return;
}
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
animationFrame.value = animateGradient(currentBackground.value, newBg, (gradient) => {
currentBackground.value = gradient;
});
} else {
currentBackground.value = newBg;
}
textColors.value = getTextColors(newBg);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty('--hover-bg-color', getHoverBackgroundColor(isDark.value));
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
},
{ immediate: true },
);
// 修改 useLyricProgress 的使用方式
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
// 修改 getLrcStyle 函数
const getLrcStyle = (index: number) => {
const colors = textColors.value || getTextColors;
const originalStyle = originalLrcStyle(index);
if (index === nowIndex.value) {
// 当前播放的歌词,使用渐变效果
return {
...originalStyle,
backgroundImage: originalStyle.backgroundImage
?.replace(/#ffffff/g, colors.active)
.replace(/#ffffff8a/g, `${colors.primary}`),
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
};
}
// 非当前播放的歌词,使用普通颜色
return {
color: colors.primary,
};
};
// 组件卸载时清理动画
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
});
defineExpose({
lrcScroll,
});
@@ -110,14 +206,12 @@ defineExpose({
}
}
.drawer-back {
@apply absolute bg-cover bg-center opacity-70;
filter: blur(80px) brightness(80%);
@apply absolute bg-cover bg-center;
z-index: -1;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
animation: round 20s linear infinite;
}
.drawer-back.paused {
@@ -125,30 +219,28 @@ defineExpose({
}
#drawer-target {
@apply top-0 left-0 absolute w-full h-full overflow-hidden rounded px-24 pt-24 pb-48 flex items-center;
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
backdrop-filter: blur(20px);
background-color: rgba(0, 0, 0, 0.747);
animation-duration: 300ms;
.music-img {
@apply flex-1 flex justify-center mr-24;
@apply flex-1 flex justify-center mr-16 flex-col;
max-width: 360px;
max-height: 360px;
.img {
width: 350px;
height: 350px;
@apply rounded-xl;
@apply rounded-xl w-full h-full shadow-2xl;
}
}
.music-content {
@apply flex flex-col justify-center items-center;
@apply flex flex-col justify-center items-center relative;
&-name {
@apply font-bold text-3xl py-2;
@apply font-bold text-xl pb-1 pt-4;
}
&-singer {
@apply text-base py-2;
@apply text-base;
}
}
@@ -156,25 +248,35 @@ defineExpose({
display: none;
@apply flex justify-center items-center;
}
.music-lrc {
background-color: inherit;
width: 500px;
height: 550px;
.now-text {
@apply text-red-500;
}
&-text {
@apply text-white text-lg flex flex-col justify-center items-center cursor-pointer font-bold;
height: 60px;
transition: all 0.2s ease-out;
@apply text-2xl cursor-pointer font-bold px-2 py-4;
transition: all 0.3s ease;
background-color: transparent;
span {
padding-right: 100px;
display: inline-block;
background-clip: text !important;
-webkit-background-clip: text !important;
}
&:hover {
@apply font-bold text-red-500;
@apply font-bold opacity-100 rounded-xl;
background-color: var(--hover-bg-color);
span {
color: var(--text-color-active) !important;
}
}
&-tr {
@apply text-sm font-normal;
@apply font-normal;
opacity: 0.7;
color: var(--text-color-primary);
}
}
}
@@ -191,4 +293,8 @@ defineExpose({
}
}
}
.music-drawer {
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
}
</style>

View File

@@ -1,8 +1,16 @@
<template>
<!-- 展开全屏 -->
<music-full ref="MusicFullRef" v-model:musicFull="musicFull" :audio="audio.value as HTMLAudioElement" />
<music-full
ref="MusicFullRef"
v-model:music-full="musicFullVisible"
:audio="audio.value as HTMLAudioElement"
:background="background"
/>
<!-- 底部播放栏 -->
<div class="music-play-bar" :class="setAnimationClass('animate__bounceInUp')">
<div
class="music-play-bar"
:class="setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')"
>
<n-image
:src="getImgUrl(playMusic?.picUrl, '300y300')"
class="play-bar-img"
@@ -35,12 +43,12 @@
<i class="iconfont icon-next"></i>
</div>
</div>
<div class="music-time">
<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">
<div class="audio-volume custom-slider">
<div>
<i class="iconfont icon-notificationfill"></i>
</div>
@@ -59,13 +67,22 @@
</template>
解析播放
</n-tooltip> -->
<n-tooltip class="music-lyric" trigger="hover" :z-index="9999999">
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont ri-netease-cloud-music-line" @click="openLyric"></i>
</template>
歌词
</n-tooltip>
<n-popover trigger="click" :z-index="99999999" content-class="music-play" raw :show-arrow="false" :delay="200">
<n-popover
trigger="click"
:z-index="99999999"
content-class="music-play"
raw
:show-arrow="false"
:delay="200"
arrow-wrapper-style=" border-radius:1.5rem"
@update-show="scrollToPlayList"
>
<template #trigger>
<n-tooltip trigger="manual" :z-index="9999999">
<template #trigger>
@@ -76,11 +93,13 @@
</template>
<div class="music-play-list">
<div class="music-play-list-back"></div>
<n-scrollbar>
<div class="music-play-list-content">
<song-item v-for="item in playList" :key="item.id" :item="item" mini></song-item>
</div>
</n-scrollbar>
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
<template #default="{ item }">
<div class="music-play-list-content">
<song-item :key="item.id" :item="item" mini></song-item>
</div>
</template>
</n-virtual-list>
</div>
</n-popover>
</div>
@@ -89,10 +108,11 @@
</template>
<script lang="ts" setup>
import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
import { allTime, loadLrc, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
import { allTime, getCurrentLrc, isElectron, nowTime, openLyric, sendLyricToWin } from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, secondToMinute, setAnimationClass } from '@/utils';
@@ -110,13 +130,14 @@ const playList = computed(() => store.state.playList as SongResult[]);
const audio = {
value: document.querySelector('#MusicAudio') as HTMLAudioElement,
};
const background = ref('#000');
watch(
() => store.state.playMusicUrl,
() => {
loadLrc(playMusic.value.id);
() => store.state.playMusic,
async () => {
background.value = playMusic.value.backgroundColor as string;
},
{ immediate: true },
{ immediate: true, deep: true },
);
const audioPlay = () => {
@@ -188,13 +209,16 @@ function handleGetAudioTime(this: HTMLAudioElement) {
// 监听音频播放的实时时间事件
const audio = this as HTMLAudioElement;
// 获取当前播放时间
nowTime.value = Math.floor(audio.currentTime);
nowTime.value = audio.currentTime;
getCurrentLrc();
// 获取总时间
allTime.value = audio.duration;
// 获取音量
audioVolume.value = audio.volume;
sendLyricToWin(store.state.isPlay);
MusicFullRef.value?.lrcScroll();
// if (musicFullVisible.value) {
// MusicFullRef.value?.lrcScroll();
// }
}
// 播放暂停按钮事件
@@ -206,11 +230,20 @@ const playMusicEvent = async () => {
}
};
const musicFull = ref(false);
const musicFullVisible = ref(false);
// 设置musicFull
const setMusicFull = () => {
musicFull.value = !musicFull.value;
musicFullVisible.value = !musicFullVisible.value;
};
const palyListRef = useTemplateRef('palyListRef');
const scrollToPlayList = (val: boolean) => {
if (!val) return;
setTimeout(() => {
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 });
}, 50);
};
</script>
@@ -223,7 +256,7 @@ const setMusicFull = () => {
@apply h-20 w-full absolute bottom-0 left-0 flex items-center rounded-t-2xl overflow-hidden box-border px-6 py-2;
z-index: 9999;
box-shadow: 0px 0px 10px 2px rgba(203, 203, 203, 0.034);
background-color: rgba(0, 0, 0, 0.747);
background-color: #212121;
animation-duration: 0.5s !important;
.music-content {
width: 140px;
@@ -234,12 +267,16 @@ const setMusicFull = () => {
}
&-name {
@apply text-xs mt-1;
@apply text-gray-400;
@apply text-xs mt-1 text-gray-100;
}
}
}
.play-bar-opcity {
@apply bg-transparent;
box-shadow: 0 0 20px 5px #0000001d;
}
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}
@@ -262,8 +299,8 @@ const setMusicFull = () => {
}
&-play {
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition;
background: #383838;
@apply flex justify-center items-center w-12 h-12 rounded-full mx-4 hover:bg-green-500 transition bg-opacity-40;
}
}
@@ -295,13 +332,14 @@ const setMusicFull = () => {
.music-play {
&-list {
height: 50vh;
@apply relative rounded-3xl overflow-hidden;
width: 300px;
@apply relative rounded-3xl overflow-hidden py-2;
&-back {
backdrop-filter: blur(20px);
@apply absolute top-0 left-0 w-full h-full bg-gray-800 bg-opacity-75;
}
&-content {
padding: 10px;
@apply mx-2;
}
}
}
@@ -337,4 +375,37 @@ const setMusicFull = () => {
flex: 1;
}
}
// 添加自定义 slider 样式
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: rgba(255, 255, 255, 0.2);
--n-fill-color: var(--primary-color);
--n-handle-size: 12px;
--n-handle-color: var(--primary-color);
&:hover {
--n-rail-height: 6px;
--n-handle-size: 14px;
}
.n-slider-rail {
@apply overflow-hidden;
}
.n-slider-handle {
@apply transition-opacity duration-200;
opacity: 0;
}
&:hover .n-slider-handle {
opacity: 1;
}
}
}
:root {
--primary-color: #18a058;
}
</style>

View File

@@ -14,7 +14,7 @@
</template>
<template #suffix>
<div class="w-20 px-3 flex justify-between items-center">
<div>{{ searchTypeOptions.find((item) => item.key === 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>
</n-dropdown>
@@ -90,7 +90,6 @@ onMounted(() => {
// 搜索词
const searchValue = ref('');
const searchType = ref(1);
const search = () => {
const { value } = searchValue;
if (value === '') {
@@ -98,17 +97,21 @@ const search = () => {
return;
}
if (router.currentRoute.value.path === '/search') {
store.state.searchValue = value;
return;
}
router.push({
path: '/search',
query: {
keyword: value,
type: searchType.value,
},
});
};
const selectSearchType = (key: number) => {
searchType.value = key;
store.state.searchType = key;
};
const searchTypeOptions = ref(SEARCH_TYPES);

View File

@@ -10,8 +10,13 @@ import router from '@/router';
import store from '@/store';
import App from './App.vue';
import directives from './directive';
const app = createApp(App);
Object.keys(directives).forEach((key: string) => {
app.directive(key, directives[key]);
});
app.use(router);
app.use(store);
app.mount('#app');

View File

@@ -1,10 +1,8 @@
import { createStore } from 'vuex';
import { getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
import { SongResult } from '@/type/music';
import { getMusicProxyUrl } from '@/utils';
import type { SongResult } from '@/type/music';
interface State {
menus: any[];
@@ -18,6 +16,8 @@ interface State {
setData: any;
lyric: any;
isMobile: boolean;
searchValue: string;
searchType: number;
}
const state: State = {
@@ -32,22 +32,19 @@ const state: State = {
setData: null,
lyric: {},
isMobile: false,
searchValue: '',
searchType: 1,
};
const windowData = window as any;
const musicHistory = useMusicHistory();
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
const mutations = {
setMenus(state: State, menus: any[]) {
state.menus = menus;
},
async setPlay(state: State, playMusic: SongResult) {
state.playMusic = { ...playMusic, playLoading: true };
state.playMusicUrl = await getSongUrl(playMusic.id);
state.play = true;
musicHistory.addMusic(playMusic);
state.playMusic.playLoading = false;
await handlePlayMusic(state, playMusic);
},
setIsPlay(state: State, isPlay: boolean) {
state.isPlay = isPlay;
@@ -60,49 +57,17 @@ const mutations = {
state.playList = playList;
},
async nextPlay(state: State) {
if (state.playList.length === 0) {
state.play = true;
return;
}
state.playListIndex = (state.playListIndex + 1) % state.playList.length;
await updatePlayMusic(state);
await nextPlay(state);
},
async prevPlay(state: State) {
if (state.playList.length === 0) {
state.play = true;
return;
}
state.playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
await updatePlayMusic(state);
await prevPlay(state);
},
async setSetData(state: State, setData: any) {
state.setData = setData;
windowData.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
window.electron && window.electron.ipcRenderer.setStoreValue('set', JSON.parse(JSON.stringify(setData)));
},
};
const getSongUrl = async (id: number) => {
const { data } = await getMusicUrl(id);
let url = '';
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(id);
url = res.data.data.url;
}
} catch (error) {
console.error('error', error);
}
url = url || data.data[0].url;
return getMusicProxyUrl(url);
};
const updatePlayMusic = async (state: State) => {
state.playMusic = state.playList[state.playListIndex];
state.playMusicUrl = await getSongUrl(state.playMusic.id);
state.play = true;
musicHistory.addMusic(state.playMusic);
};
const store = createStore({
state,
mutations,

View File

@@ -3,6 +3,14 @@ export interface IRecommendMusic {
category: number;
result: SongResult[];
}
export interface ILyricText {
text: string;
trText: string;
}
export interface ILyric {
lrcTimeArray: number[];
lrcArray: ILyricText[];
}
export interface SongResult {
id: number;
@@ -16,9 +24,15 @@ export interface SongResult {
alg: string;
count?: number;
playLoading?: boolean;
ar?: Artist[];
al?: Album;
backgroundColor?: string;
primaryColor?: string;
playMusicUrl?: string;
lyric?: ILyric;
}
interface Song {
export interface Song {
name: string;
id: number;
position: number;
@@ -64,6 +78,9 @@ interface Song {
lMusic: BMusic;
exclusive: boolean;
privilege: Privilege;
count?: number;
playLoading?: boolean;
picUrl?: string;
}
interface Privilege {

View File

@@ -8,6 +8,9 @@ export const setBackgroundImg = (url: String) => {
};
// 设置动画类型
export const setAnimationClass = (type: String) => {
if (store.state.setData && store.state.setData.noAnimate) {
return '';
}
return `animate__animated ${type}`;
};
// 设置动画延时
@@ -28,16 +31,21 @@ export const secondToMinute = (s: number) => {
};
// 格式化数字 千,万, 百万, 千万,亿
const units = [
{ value: 1e8, symbol: '亿' },
{ value: 1e4, symbol: '万' },
];
export const formatNumber = (num: string | number) => {
num = Number(num);
if (num < 10000) {
return num;
for (let i = 0; i < units.length; i++) {
if (num >= units[i].value) {
return `${(num / units[i].value).toFixed(1)}${units[i].symbol}`;
}
}
if (num < 100000000) {
return `${(num / 10000).toFixed(1)}`;
}
return `${(num / 100000000).toFixed(1)}亿`;
return num.toString();
};
const windowData = window as any;
export const getIsMc = () => {
if (!windowData.electron) {
@@ -46,6 +54,8 @@ export const getIsMc = () => {
if (windowData.electron.ipcRenderer.getStoreValue('set').isProxy) {
return true;
}
if (window.location.origin.includes('localhost')) {
}
return false;
};
const ProxyUrl = import.meta.env.VITE_API_PROXY || 'http://110.42.251.190:9856';
@@ -58,14 +68,14 @@ export const getMusicProxyUrl = (url: string) => {
return `${ProxyUrl}/mc?url=${PUrl}`;
};
export const getImgUrl = computed(() => (url: string | undefined, size: string = '') => {
export const getImgUrl = (url: string | undefined, size: string = '') => {
const bdUrl = 'https://image.baidu.com/search/down?url=';
const imgUrl = `${url}?param=${size}`;
if (!getIsMc()) {
return imgUrl;
}
return `${bdUrl}${encodeURIComponent(imgUrl)}`;
});
};
export const isMobile = computed(() => {
const flag = navigator.userAgent.match(

271
src/utils/linearColor.ts Normal file
View File

@@ -0,0 +1,271 @@
interface IColor {
backgroundColor: string;
primaryColor: string;
}
export const getImageLinearBackground = async (imageSrc: string): Promise<IColor> => {
try {
const primaryColor = await getImagePrimaryColor(imageSrc);
return {
backgroundColor: generateGradientBackground(primaryColor),
primaryColor,
};
} catch (error) {
console.error('error', error);
return {
backgroundColor: '',
primaryColor: '',
};
}
};
export const getImageBackground = async (img: HTMLImageElement): Promise<IColor> => {
try {
const primaryColor = await getImageColor(img);
return {
backgroundColor: generateGradientBackground(primaryColor),
primaryColor,
};
} catch (error) {
console.error('error', error);
return {
backgroundColor: '',
primaryColor: '',
};
}
};
const getImageColor = (img: HTMLImageElement): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = getAverageColor(imageData.data);
resolve(`rgb(${color.join(',')})`);
});
};
const getImagePrimaryColor = (imageSrc: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = imageSrc;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = getAverageColor(imageData.data);
resolve(`rgb(${color.join(',')})`);
};
img.onerror = () => reject(new Error('图片加载失败'));
});
};
const getAverageColor = (data: Uint8ClampedArray): number[] => {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
};
const generateGradientBackground = (color: string): string => {
const [r, g, b] = color.match(/\d+/g)?.map(Number) || [0, 0, 0];
const [h, s, l] = rgbToHsl(r, g, b);
// 增加亮度和暗度的差异
const lightL = Math.min(l + 0.2, 0.95);
const darkL = Math.max(l - 0.3, 0.05);
const midL = (lightL + darkL) / 2;
// 调整饱和度以增强效果
const lightS = Math.min(s * 0.8, 1);
const darkS = Math.min(s * 1.2, 1);
const [lightR, lightG, lightB] = hslToRgb(h, lightS, lightL);
const [midR, midG, midB] = hslToRgb(h, s, midL);
const [darkR, darkG, darkB] = hslToRgb(h, darkS, darkL);
const lightColor = `rgb(${lightR}, ${lightG}, ${lightB})`;
const midColor = `rgb(${midR}, ${midG}, ${midB})`;
const darkColor = `rgb(${darkR}, ${darkG}, ${darkB})`;
// 使用三个颜色点创建更丰富的渐变
return `linear-gradient(to bottom, ${lightColor} 0%, ${midColor} 50%, ${darkColor} 100%)`;
};
// Helper functions (unchanged)
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
break;
}
h /= 6;
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r;
let g;
let b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
// 添加新的接口
interface ITextColors {
primary: string;
active: string;
}
// 添加新的函数
export const calculateBrightness = (r: number, g: number, b: number): number => {
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
};
export const parseGradient = (gradientStr: string) => {
const matches = gradientStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g);
if (!matches) return [];
return matches.map((rgb) => {
const [r, g, b] = rgb.match(/\d+/g)!.map(Number);
return { r, g, b };
});
};
export const interpolateRGB = (start: number, end: number, progress: number) => {
return Math.round(start + (end - start) * progress);
};
export const createGradientString = (colors: { r: number; g: number; b: number }[], percentages = [0, 50, 100]) => {
return `linear-gradient(to bottom, ${colors
.map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`)
.join(', ')})`;
};
export const getTextColors = (gradient: string = ''): ITextColors => {
const defaultColors = {
primary: 'rgba(255, 255, 255, 0.54)',
active: '#ffffff',
};
if (!gradient) return defaultColors;
const colors = parseGradient(gradient);
if (!colors.length) return defaultColors;
const mainColor = colors[1] || colors[0];
const brightness = calculateBrightness(mainColor.r, mainColor.g, mainColor.b);
const isDark = brightness > 0.6;
return {
primary: isDark ? 'rgba(0, 0, 0, 0.54)' : 'rgba(255, 255, 255, 0.54)',
active: isDark ? '#000000' : '#ffffff',
};
};
export const getHoverBackgroundColor = (isDark: boolean): string => {
return isDark ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.08)';
};
export const animateGradient = (
oldGradient: string,
newGradient: string,
onUpdate: (gradient: string) => void,
duration = 1000,
) => {
const startColors = parseGradient(oldGradient);
const endColors = parseGradient(newGradient);
if (startColors.length !== endColors.length) return null;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const currentColors = startColors.map((startColor, i) => ({
r: interpolateRGB(startColor.r, endColors[i].r, progress),
g: interpolateRGB(startColor.g, endColors[i].g, progress),
b: interpolateRGB(startColor.b, endColors[i].b, progress),
}));
onUpdate(createGradientString(currentColors));
if (progress < 1) {
return requestAnimationFrame(animate);
}
return null;
};
return requestAnimationFrame(animate);
};

View File

@@ -10,7 +10,7 @@
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)"
>
<song-item class="history-item-content" :item="item" />
<song-item class="history-item-content" :item="item" @play="handlePlay" />
<div class="history-item-count">
{{ item.count }}
</div>
@@ -24,6 +24,8 @@
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { setAnimationClass, setAnimationDelay } from '@/utils';
@@ -31,7 +33,12 @@ defineOptions({
name: 'History',
});
const store = useStore();
const { delMusic, musicList } = useMusicHistory();
const handlePlay = () => {
store.commit('setPlayList', musicList.value);
};
</script>
<style scoped lang="scss">

View File

@@ -16,19 +16,24 @@ const showMusic = ref(false);
const recommendItem = ref<IRecommendItem | null>();
const listDetail = ref<IListDetail | null>();
const listLoading = ref(true);
const selectRecommendItem = async (item: IRecommendItem) => {
listLoading.value = true;
recommendItem.value = null;
listDetail.value = null;
showMusic.value = true;
recommendItem.value = item;
const { data } = await getListDetail(item.id);
listDetail.value = data;
listLoading.value = false;
};
const route = useRoute();
const listTitle = ref(route.query.type || '歌单列表');
const loading = ref(false);
const loadList = async (type: string) => {
loading.value = true;
const params = {
cat: type || '',
limit: 30,
@@ -36,6 +41,7 @@ const loadList = async (type: string) => {
};
const { data } = await getListByCat(params);
recommendList.value = data.playlists;
loading.value = false;
};
if (route.query.type) {
@@ -51,6 +57,7 @@ watch(
async (newParams) => {
if (newParams.type) {
recommendList.value = null;
listTitle.value = newParams.type || '歌单列表';
loadList(newParams.type as string);
}
},
@@ -62,7 +69,7 @@ watch(
<div class="recommend-title" :class="setAnimationClass('animate__bounceInLeft')">{{ listTitle }}</div>
<!-- 歌单列表 -->
<n-scrollbar class="recommend" :size="100" @click="showMusic = false">
<div v-if="recommendList" class="recommend-list">
<div v-loading="loading" class="recommend-list">
<div
v-for="(item, index) in recommendList"
:key="item.id"
@@ -91,8 +98,10 @@ watch(
</n-scrollbar>
<music-list
v-model:show="showMusic"
v-model:loading="listLoading"
:name="recommendItem?.name || ''"
:song-list="listDetail?.playlist.tracks || []"
:list-info="listDetail?.playlist"
/>
</div>
</template>

View File

@@ -70,6 +70,14 @@ const timerIsQr = (key: string) => {
return timer;
};
// 离开页面时
onBeforeUnmount(() => {
if (timerRef.value) {
clearInterval(timerRef.value);
timerRef.value = null;
}
});
// 是否扫码登陆
const isQr = ref(!isMobile.value);
const chooseQr = () => {

View File

@@ -3,16 +3,16 @@
<div class="mv-list-title">
<h2>推荐MV</h2>
</div>
<n-scrollbar :size="100">
<div class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<n-scrollbar :size="100" @scroll="handleScroll">
<div v-loading="initLoading" class="mv-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div
v-for="(item, index) in mvList"
:key="item.id"
class="mv-item"
:class="setAnimationClass('animate__bounceIn')"
:style="setAnimationDelay(index, 30)"
:style="setAnimationDelay(index, 10)"
>
<div class="mv-item-img" @click="handleShowMv(item)">
<div class="mv-item-img" @click="handleShowMv(item, index)">
<n-image
class="mv-item-img-img"
:src="getImgUrl(item.cover, '200y112')"
@@ -28,28 +28,28 @@
</div>
<div class="mv-item-title">{{ item.name }}</div>
</div>
<div v-if="loadingMore" class="loading-more">加载中...</div>
<div v-if="!hasMore && !initLoading" class="no-more">没有更多了</div>
</div>
</n-scrollbar>
<n-drawer :show="showMv" height="100vh" placement="bottom" :z-index="999999999">
<div class="mv-detail">
<video :src="playMvUrl" controls autoplay></video>
<div class="mv-detail-title">
<div class="title">{{ playMvItem?.name }}</div>
<button @click="close">
<i class="iconfont icon-xiasanjiaoxing"></i>
</button>
</div>
</div>
</n-drawer>
<mv-player
v-model:show="showMv"
:current-mv="playMvItem"
:is-prev-disabled="isPrevDisabled"
@next="playNextMv"
@prev="playPrevMv"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getMvUrl, getTopMv } from '@/api/mv';
import { getTopMv } from '@/api/mv';
import MvPlayer from '@/components/MvPlayer.vue';
import { IMvItem } from '@/type/mv';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
@@ -60,29 +60,99 @@ defineOptions({
const showMv = ref(false);
const mvList = ref<Array<IMvItem>>([]);
const playMvItem = ref<IMvItem>();
const playMvUrl = ref<string>();
const store = useStore();
const initLoading = ref(false);
const loadingMore = ref(false);
const currentIndex = ref(0);
const offset = ref(0);
const limit = ref(30);
const hasMore = ref(true);
onMounted(async () => {
const res = await getTopMv(30);
mvList.value = res.data.data;
await loadMvList();
});
const handleShowMv = async (item: IMvItem) => {
const handleShowMv = async (item: IMvItem, index: number) => {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
showMv.value = true;
const res = await getMvUrl(item.id);
currentIndex.value = index;
playMvItem.value = item;
playMvUrl.value = res.data.data.url;
};
const close = () => {
showMv.value = false;
if (store.state.playMusicUrl) {
store.commit('setIsPlay', true);
const playPrevMv = async (setLoading: (value: boolean) => void) => {
try {
if (currentIndex.value > 0) {
const prevItem = mvList.value[currentIndex.value - 1];
await handleShowMv(prevItem, currentIndex.value - 1);
}
} finally {
setLoading(false);
}
};
const playNextMv = async (setLoading: (value: boolean) => void) => {
try {
if (currentIndex.value < mvList.value.length - 1) {
const nextItem = mvList.value[currentIndex.value + 1];
await handleShowMv(nextItem, currentIndex.value + 1);
} else if (hasMore.value) {
await loadMvList();
if (mvList.value.length > currentIndex.value + 1) {
const nextItem = mvList.value[currentIndex.value + 1];
await handleShowMv(nextItem, currentIndex.value + 1);
} else {
showMv.value = false;
}
} else {
showMv.value = false;
}
} catch (error) {
console.error('加载更多MV失败:', error);
showMv.value = false;
} finally {
setLoading(false);
}
};
const loadMvList = async () => {
if (!hasMore.value || loadingMore.value) return;
if (offset.value === 0) {
initLoading.value = true;
} else {
loadingMore.value = true;
}
try {
const res = await getTopMv(limit.value, offset.value);
if (offset.value === 0) {
mvList.value = res.data.data;
} else {
mvList.value.push(...res.data.data);
}
hasMore.value = res.data.data.length === limit.value;
offset.value += limit.value;
} catch (error) {
console.error('加载MV失败:', error);
} finally {
initLoading.value = false;
loadingMore.value = false;
}
};
const handleScroll = (e: Event) => {
const target = e.target as Element;
const { scrollTop, clientHeight, scrollHeight } = target;
const threshold = 100;
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
loadMvList();
}
};
const isPrevDisabled = computed(() => currentIndex.value === 0);
</script>
<style scoped lang="scss">
@@ -147,36 +217,14 @@ const close = () => {
}
}
.mv-detail {
@apply w-full h-full bg-black relative;
&-title {
@apply absolute w-full left-0 flex justify-between h-16 px-6 py-2 text-xl font-bold items-center z-50 transition-all duration-300 ease-in-out -top-24;
background: linear-gradient(0, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%);
button .icon-xiasanjiaoxing {
@apply text-3xl;
}
button:hover {
@apply text-green-400;
}
}
video {
@apply w-full h-full;
}
video:hover + .mv-detail-title {
@apply top-0;
}
.mv-detail-title:hover {
@apply top-0;
}
}
.mobile {
.mv-list-content {
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
}
}
.loading-more,
.no-more {
@apply col-span-full text-center py-4 text-gray-400;
}
</style>

View File

@@ -13,7 +13,7 @@
:class="setAnimationClass('animate__bounceInLeft')"
:style="setAnimationDelay(index, 10)"
class="hot-search-item"
@click.stop="clickHotKeyword(item.searchWord)"
@click.stop="loadSearch(item.searchWord, 1)"
>
<span class="hot-search-item-count" :class="{ 'hot-search-item-count-3': index < 3 }">{{ index + 1 }}</span>
{{ item.searchWord }}
@@ -29,32 +29,30 @@
:native-scrollbar="false"
>
<div class="title">{{ hotKeyword }}</div>
<n-spin :show="searchDetailLoading">
<div class="search-list-box">
<template v-if="searchDetail">
<div
v-for="(item, index) in searchDetail?.songs"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<song-item :item="item" @play="handlePlay" />
</div>
<template v-for="(list, key) in searchDetail">
<template v-if="key.toString() !== 'songs'">
<div
v-for="(item, index) in list"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SearchItem :item="item" />
</div>
</template>
<div v-loading="searchDetailLoading" class="search-list-box">
<template v-if="searchDetail">
<div
v-for="(item, index) in searchDetail?.songs"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<song-item :item="item" @play="handlePlay" />
</div>
<template v-for="(list, key) in searchDetail">
<template v-if="key.toString() !== 'songs'">
<div
v-for="(item, index) in list"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SearchItem :item="item" />
</div>
</template>
</template>
</div>
</n-spin>
</template>
</div>
</n-layout>
</div>
</template>
@@ -62,7 +60,7 @@
<script lang="ts" setup>
import { useDateFormat } from '@vueuse/core';
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { getHotSearch } from '@/api/home';
@@ -76,9 +74,10 @@ defineOptions({
});
const route = useRoute();
const router = useRouter();
const store = useStore();
const searchDetail = ref<any>();
const searchType = ref(Number(route.query.type) || 1);
const searchType = computed(() => store.state.searchType as number);
const searchDetailLoading = ref(false);
// 热搜列表
@@ -90,29 +89,26 @@ const loadHotSearch = async () => {
onMounted(() => {
loadHotSearch();
loadSearch(route.query.keyword);
});
const hotKeyword = ref(route.query.keyword || '搜索列表');
const clickHotKeyword = (keyword: string) => {
hotKeyword.value = keyword;
router.push({
path: '/search',
query: {
keyword,
type: 1,
},
});
// isHotSearchList.value = false;
};
watch(
() => store.state.searchValue,
(value) => {
loadSearch(value);
},
);
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
const loadSearch = async (keywords: any) => {
const loadSearch = async (keywords: any, type: any = null) => {
hotKeyword.value = keywords;
searchDetail.value = undefined;
if (!keywords) return;
searchDetailLoading.value = true;
const { data } = await getSearch({ keywords, type: searchType.value });
const { data } = await getSearch({ keywords, type: type || searchType.value });
const songs = data.result.songs || [];
const albums = data.result.albums || [];
@@ -135,7 +131,6 @@ const loadSearch = async (keywords: any) => {
// songs map 替换属性
songs.forEach((item: any) => {
item.picUrl = item.al.picUrl;
item.song = item;
item.artists = item.ar;
});
albums.forEach((item: any) => {
@@ -151,18 +146,15 @@ const loadSearch = async (keywords: any) => {
searchDetailLoading.value = false;
};
loadSearch(route.query.keyword);
watch(
() => route.query,
async (newParams) => {
searchType.value = Number(newParams.type || 1);
loadSearch(newParams.keyword);
() => route.path,
async (path) => {
if (path === '/search') {
store.state.searchValue = route.query.keyword;
}
},
);
const store = useStore();
const handlePlay = () => {
const tracks = searchDetail.value?.songs || [];
store.commit('setPlayList', tracks);

View File

@@ -7,12 +7,18 @@
</div>
<n-switch v-model:value="setData.isProxy" />
</div>
<div class="set-item">
<div>
<div class="set-item-title">减轻动画效果</div>
</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>
<div>{{ setData.version }}</div>
<div>{{ config.version }}</div>
</div>
<div class="set-item">
<div>
@@ -32,6 +38,7 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import config from '@/../package.json';
import store from '@/store';
defineOptions({
@@ -49,7 +56,9 @@ const windowData = window as any;
const handleSave = () => {
store.commit('setSetData', setData.value);
windowData.electronAPI.restart();
if (windowData.electronAPI) {
windowData.electronAPI.restart();
}
};
</script>

View File

@@ -21,6 +21,7 @@ const router = useRouter();
const userDetail = ref<IUserDetail>();
const playList = ref<any[]>([]);
const recordList = ref();
const infoLoading = ref(false);
const user = computed(() => store.state.user);
@@ -29,6 +30,7 @@ const loadPage = async () => {
router.push('/login');
return;
}
infoLoading.value = true;
const { data: userData } = await getUserDetail(user.value.userId);
userDetail.value = userData;
@@ -36,9 +38,13 @@ const loadPage = async () => {
const { data: playlistData } = await getUserPlaylist(user.value.userId);
playList.value = playlistData.playlist;
getUserRecord(user.value.userId).then(({ data: recordData }) => {
recordList.value = recordData.allData;
});
const { data: recordData } = await getUserRecord(user.value.userId);
recordList.value = recordData.allData.map((item: any) => ({
...item,
...item.song,
picUrl: item.song.al.picUrl,
}));
infoLoading.value = false;
};
onActivated(() => {
@@ -59,19 +65,6 @@ const showPlaylist = async (id: number) => {
list.value = data.playlist;
};
// 格式化歌曲列表项
const formatDetail = computed(() => (detail: any) => {
const song = {
artists: detail.ar,
name: detail.al.name,
id: detail.al.id,
};
detail.song = song;
detail.picUrl = detail.al.picUrl;
return detail;
});
const handlePlay = () => {
const tracks = recordList.value || [];
store.commit('setPlayList', tracks);
@@ -108,7 +101,7 @@ const handlePlay = () => {
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class=" ">创建的歌单</div>
<div class="title">创建的歌单</div>
<n-scrollbar>
<div v-for="(item, index) in playList" :key="index" class="play-list-item" @click="showPlaylist(item.id)">
<n-image :src="getImgUrl(item.coverImgUrl, '50y50')" class="play-list-item-img" lazy preview-disabled />
@@ -122,25 +115,25 @@ const handlePlay = () => {
</div>
</div>
</div>
<div v-if="!isMobile" class="right" :class="setAnimationClass('animate__fadeInRight')">
<div v-if="!isMobile" v-loading="infoLoading" class="right" :class="setAnimationClass('animate__fadeInRight')">
<div class="title">听歌排行</div>
<div class="record-list">
<n-scrollbar>
<div
v-for="(item, index) in recordList"
:key="item.song.id"
:key="item.id"
class="record-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 50)"
:style="setAnimationDelay(index, 25)"
>
<song-item class="song-item" :item="formatDetail(item.song)" @play="handlePlay" />
<song-item class="song-item" :item="item" @play="handlePlay" />
<div class="play-count">{{ item.playCount }}</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</div>
<music-list v-model:show="isShowList" :name="list?.name || ''" :song-list="list?.tracks || []" />
<music-list v-model:show="isShowList" :name="list?.name || ''" :song-list="list?.tracks || []" :list-info="list" />
</div>
</template>

View File

@@ -33,7 +33,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
// 指定端口
port: 4678,
port: 4488,
proxy: {
// with options
'/api': {