Compare commits

..

5 Commits
2.0.0 ... 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
19 changed files with 1397 additions and 96 deletions

View File

@@ -129,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

4
app.js
View File

@@ -24,7 +24,9 @@ function createWindow() {
} 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
}
}
}

2
components.d.ts vendored
View File

@@ -9,6 +9,7 @@ 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']
@@ -23,6 +24,7 @@ declare module 'vue' {
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']

View File

@@ -1,18 +1,19 @@
{
"name": "alger-music",
"version": "2.0.0",
"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"
@@ -56,6 +57,7 @@
"vue": "^3.5.0",
"vue-router": "^4.4.3",
"vue-tsc": "^2.1.4",
"vuex": "^4.1.0"
"vuex": "^4.1.0",
"cross-env": "^7.0.3"
}
}

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,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,
},
});
};

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>

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

@@ -44,6 +44,8 @@ export const useMusicListHook = () => {
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);

View File

@@ -1,5 +1,10 @@
<template>
<n-drawer :show="musicFull" height="100vh" placement="bottom" :style="{ background: background }">
<n-drawer
:show="musicFull"
height="100vh"
placement="bottom"
:style="{ background: currentBackground || background }"
>
<div id="drawer-target">
<div class="drawer-back"></div>
<div class="music-img">
@@ -48,25 +53,22 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import { onBeforeUnmount, ref, watch } from 'vue';
import {
addCorrectionTime,
lrcArray,
nowIndex,
playMusic,
reduceCorrectionTime,
setAudioTime,
useLyricProgress,
} from '@/hooks/MusicHook';
import { lrcArray, nowIndex, playMusic, setAudioTime, useLyricProgress } from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
const { getLrcStyle } = useLyricProgress();
// const isPlaying = computed(() => store.state.play as boolean);
// 获取歌词滚动dom
// 定义 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: {
@@ -122,6 +124,73 @@ watch(
},
);
// 监听背景变化
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,
});
@@ -185,19 +254,29 @@ defineExpose({
height: 550px;
&-text {
@apply text-2xl cursor-pointer font-bold px-2 py-4;
color: #ffffff8a;
// transition: all 0.5s ease;
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 opacity-100 rounded-xl;
background-color: #ffffff26;
color: #fff;
background-color: var(--hover-bg-color);
span {
color: var(--text-color-active) !important;
}
}
&-tr {
@apply font-normal;
opacity: 0.7;
color: var(--text-color-primary);
}
}
}
@@ -214,4 +293,8 @@ defineExpose({
}
}
}
.music-drawer {
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
}
</style>

View File

@@ -43,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>
@@ -267,8 +267,7 @@ const scrollToPlayList = (val: boolean) => {
}
&-name {
@apply text-xs mt-1;
@apply text-gray-400;
@apply text-xs mt-1 text-gray-100;
}
}
}
@@ -376,4 +375,37 @@ const scrollToPlayList = (val: boolean) => {
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

@@ -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}`;
};
// 设置动画延时
@@ -51,7 +54,8 @@ export const getIsMc = () => {
if (windowData.electron.ipcRenderer.getStoreValue('set').isProxy) {
return true;
}
if(window.location.origin.includes('localhost')){}
if (window.location.origin.includes('localhost')) {
}
return false;
};
const ProxyUrl = import.meta.env.VITE_API_PROXY || 'http://110.42.251.190:9856';

View File

@@ -181,3 +181,91 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
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

@@ -3,16 +3,16 @@
<div class="mv-list-title">
<h2>推荐MV</h2>
</div>
<n-scrollbar :size="100">
<div v-loading="loading" 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 v-loading="mvLoading" 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,35 +60,99 @@ defineOptions({
const showMv = ref(false);
const mvList = ref<Array<IMvItem>>([]);
const playMvItem = ref<IMvItem>();
const playMvUrl = ref<string>();
const store = useStore();
const loading = ref(false);
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 () => {
loading.value = true;
const res = await getTopMv(30);
mvList.value = res.data.data;
loading.value = false;
await loadMvList();
});
const mvLoading = ref(false);
const handleShowMv = async (item: IMvItem) => {
mvLoading.value = true;
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;
mvLoading.value = false;
};
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">
@@ -153,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

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