mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-24 08:07:23 +08:00
🦄 refactor: 重构整个项目 优化打包 修改后台服务为本地运行 添加更新版本检测功能
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<n-popover trigger="hover" placement="top" :show-arrow="true" :raw="true" :delay="100">
|
||||
<template #trigger>
|
||||
<slot>
|
||||
<n-button
|
||||
quaternary
|
||||
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
请我喝咖啡
|
||||
</n-button>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800">
|
||||
<div class="flex gap-10">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
:src="alipayQR"
|
||||
alt="支付宝收款码"
|
||||
class="w-32 h-32 rounded-lg cursor-none"
|
||||
preview-disabled
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<n-image
|
||||
:src="wechatQR"
|
||||
alt="微信收款码"
|
||||
class="w-32 h-32 rounded-lg cursor-none"
|
||||
preview-disabled
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p
|
||||
class="text-sm text-gray-700 dark:text-gray-200 text-center cursor-pointer hover:text-green-500"
|
||||
@click="copyQQ"
|
||||
>
|
||||
QQ群:789288579
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { NButton, NImage, NPopover } from 'naive-ui';
|
||||
|
||||
const message = useMessage();
|
||||
const copyQQ = () => {
|
||||
navigator.clipboard.writeText('789288579');
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
defineProps({
|
||||
alipayQR: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
wechatQR: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<n-drawer
|
||||
:show="show"
|
||||
:height="isMobile ? '100%' : '80%'"
|
||||
placement="bottom"
|
||||
block-scroll
|
||||
mask-closable
|
||||
:style="{ backgroundColor: 'transparent' }"
|
||||
:to="`#layout-main`"
|
||||
@mask-click="close"
|
||||
>
|
||||
<div class="music-page">
|
||||
<div class="music-header h-12 flex items-center justify-between">
|
||||
<n-ellipsis :line-clamp="1">
|
||||
<div class="music-title">
|
||||
{{ name }}
|
||||
</div>
|
||||
</n-ellipsis>
|
||||
<div class="music-close">
|
||||
<i class="icon iconfont icon-icon_error" @click="close"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="music-content">
|
||||
<!-- 左侧歌单信息 -->
|
||||
<div class="music-info">
|
||||
<div class="music-cover">
|
||||
<n-image
|
||||
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '500y500')"
|
||||
class="cover-img"
|
||||
preview-disabled
|
||||
:class="setAnimationClass('animate__fadeIn')"
|
||||
object-fit="cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="listInfo?.creator" class="creator-info">
|
||||
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
|
||||
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
|
||||
</div>
|
||||
|
||||
<n-scrollbar style="max-height: 200">
|
||||
<div v-if="listInfo?.description" class="music-desc">
|
||||
{{ listInfo.description }}
|
||||
</div>
|
||||
<play-bottom />
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 右侧歌曲列表 -->
|
||||
<div class="music-list-container">
|
||||
<div class="music-list">
|
||||
<n-scrollbar @scroll="handleScroll">
|
||||
<n-spin :show="loadingList || loading">
|
||||
<div class="music-list-content">
|
||||
<div
|
||||
v-for="(item, index) in displayedSongs"
|
||||
:key="item.id"
|
||||
class="double-item"
|
||||
:class="setAnimationClass('animate__bounceInUp')"
|
||||
:style="getItemAnimationDelay(index)"
|
||||
>
|
||||
<song-item :item="formatDetail(item)" @play="handlePlay" />
|
||||
</div>
|
||||
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
|
||||
<play-bottom />
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<play-bottom />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getMusicDetail } from '@/api/music';
|
||||
import SongItem from '@/components/common/SongItem.vue';
|
||||
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
import PlayBottom from './common/PlayBottom.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
name: string;
|
||||
songList: any[];
|
||||
loading?: boolean;
|
||||
listInfo?: {
|
||||
trackIds: { id: number }[];
|
||||
[key: string]: any;
|
||||
};
|
||||
cover?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
cover: true
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:show', 'update:loading']);
|
||||
|
||||
const page = ref(0);
|
||||
const pageSize = 20;
|
||||
const isLoadingMore = ref(false);
|
||||
const displayedSongs = ref<any[]>([]);
|
||||
const loadingList = ref(false);
|
||||
|
||||
// 计算总数
|
||||
const total = computed(() => {
|
||||
if (props.listInfo?.trackIds) {
|
||||
return props.listInfo.trackIds.length;
|
||||
}
|
||||
return props.songList.length;
|
||||
});
|
||||
|
||||
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 = props.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 (isLoadingMore.value || displayedSongs.value.length >= total.value) return;
|
||||
|
||||
isLoadingMore.value = true;
|
||||
try {
|
||||
if (props.listInfo?.trackIds) {
|
||||
// 如果有 trackIds,需要分批请求歌曲详情
|
||||
const start = page.value * pageSize;
|
||||
const end = Math.min((page.value + 1) * pageSize, total.value);
|
||||
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
|
||||
|
||||
if (trackIds.length > 0) {
|
||||
const { data } = await getMusicDetail(trackIds);
|
||||
displayedSongs.value = [...displayedSongs.value, ...data.songs];
|
||||
page.value++;
|
||||
}
|
||||
} else {
|
||||
// 如果没有 trackIds,直接使用 songList 分页
|
||||
const start = page.value * pageSize;
|
||||
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
|
||||
const newSongs = props.songList.slice(start, end);
|
||||
displayedSongs.value = [...displayedSongs.value, ...newSongs];
|
||||
page.value++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载歌曲失败:', error);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
loadingList.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getItemAnimationDelay = (index: number) => {
|
||||
const currentPageIndex = index % pageSize;
|
||||
return setAnimationDelay(currentPageIndex, 20);
|
||||
};
|
||||
|
||||
// 修改滚动处理函数
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) {
|
||||
loadMoreSongs();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
loadingList.value = newVal;
|
||||
if (!props.cover) {
|
||||
loadingList.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听 songList 变化,重置分页状态
|
||||
watch(
|
||||
() => props.songList,
|
||||
(newSongs) => {
|
||||
page.value = 0;
|
||||
displayedSongs.value = newSongs.slice(0, pageSize);
|
||||
if (newSongs.length > pageSize) {
|
||||
page.value = 1;
|
||||
}
|
||||
loadingList.value = false;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.music {
|
||||
&-title {
|
||||
@apply text-xl font-bold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-page {
|
||||
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
&-close {
|
||||
@apply cursor-pointer text-gray-900 dark:text-white flex gap-2 items-center;
|
||||
.icon {
|
||||
@apply text-3xl;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply flex h-[calc(100%-60px)];
|
||||
}
|
||||
|
||||
&-info {
|
||||
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
|
||||
|
||||
.music-cover {
|
||||
@apply w-full aspect-square rounded-2xl overflow-hidden mb-4 min-h-[250px];
|
||||
.cover-img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
|
||||
.creator-info {
|
||||
@apply flex items-center mb-4;
|
||||
.creator-name {
|
||||
@apply ml-2 text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.music-desc {
|
||||
@apply text-sm text-gray-600 dark:text-gray-400 leading-relaxed pr-4;
|
||||
}
|
||||
}
|
||||
|
||||
&-list {
|
||||
@apply flex-grow min-h-0;
|
||||
&-container {
|
||||
@apply flex-grow min-h-0 flex flex-col relative;
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply min-h-[calc(80vh-60px)];
|
||||
}
|
||||
|
||||
:deep(.n-virtual-list__scroll) {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.music-page {
|
||||
@apply px-4;
|
||||
}
|
||||
|
||||
.music-content {
|
||||
@apply flex-col;
|
||||
}
|
||||
|
||||
.music-info {
|
||||
@apply w-full pr-0 mb-2 flex flex-row;
|
||||
|
||||
.music-cover {
|
||||
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
|
||||
}
|
||||
.music-detail {
|
||||
@apply flex-1 ml-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
@apply text-center py-4 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.double-item {
|
||||
@apply mb-2 bg-light-100 bg-opacity-20 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,695 @@
|
||||
<template>
|
||||
<n-drawer :show="show" height="100%" placement="bottom" :z-index="999999999" :to="`#layout-main`">
|
||||
<div class="mv-detail">
|
||||
<div
|
||||
ref="videoContainerRef"
|
||||
class="video-container"
|
||||
:class="{ 'cursor-hidden': !showCursor }"
|
||||
>
|
||||
<video
|
||||
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 v-if="!props.noList" placement="top">
|
||||
<template #trigger>
|
||||
<n-button quaternary circle @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 v-if="!props.noList" 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 v-if="!isMobile" 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 v-if="!props.noList" 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 } from 'naive-ui';
|
||||
import { computed, 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 = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
currentMv?: IMvItem;
|
||||
noList?: boolean;
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
currentMv: undefined,
|
||||
noList: false
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
unlockScreenOrientation();
|
||||
});
|
||||
|
||||
// 监听 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', (value: boolean) => {
|
||||
nextLoading.value = value;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 as any)?.webkitRequestFullscreen ||
|
||||
(videoContainerRef.value as any)?.mozRequestFullScreen ||
|
||||
(videoContainerRef.value as any)?.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 lockScreenOrientation = async () => {
|
||||
try {
|
||||
if ('orientation' in screen) {
|
||||
await (screen as any).orientation.lock('landscape');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法锁定屏幕方向:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockScreenOrientation = () => {
|
||||
try {
|
||||
if ('orientation' in screen) {
|
||||
(screen as any).orientation.unlock();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法解锁屏幕方向:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 修改切换全屏状态的方法
|
||||
const toggleFullscreen = async () => {
|
||||
const api = checkFullscreenAPI();
|
||||
|
||||
if (!api.fullscreenEnabled) {
|
||||
console.warn('全屏API不可用');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!api.fullscreenElement) {
|
||||
await videoContainerRef.value?.requestFullscreen();
|
||||
isFullscreen.value = true;
|
||||
// 在移动端进入全屏时锁定横屏
|
||||
if (window.innerWidth <= 768) {
|
||||
await lockScreenOrientation();
|
||||
}
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
isFullscreen.value = false;
|
||||
// 退出全屏时解锁屏幕方向
|
||||
if (window.innerWidth <= 768) {
|
||||
unlockScreenOrientation();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换全屏失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听全屏状态变化
|
||||
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);
|
||||
});
|
||||
|
||||
// 添加提示状态
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = computed(() => store.state.isMobile);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mv-detail {
|
||||
@apply h-full bg-light dark:bg-black;
|
||||
|
||||
&-title {
|
||||
@apply fixed top-0 left-0 right-0 p-4 z-10 transition-opacity duration-300;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent);
|
||||
|
||||
.title {
|
||||
@apply text-white text-lg font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-container {
|
||||
@apply h-full w-full relative;
|
||||
|
||||
.video-player {
|
||||
@apply h-full w-full object-contain bg-black;
|
||||
}
|
||||
|
||||
.play-hint {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50;
|
||||
.n-button {
|
||||
@apply text-white hover:text-green-500 transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-controls {
|
||||
@apply absolute bottom-0 left-0 right-0 p-4 transition-opacity duration-300;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
|
||||
|
||||
.controls-main {
|
||||
@apply flex justify-between items-center;
|
||||
|
||||
.left-controls,
|
||||
.right-controls {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
.n-button {
|
||||
@apply text-white hover:text-green-500 transition-colors;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@apply text-white text-sm ml-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mode-hint {
|
||||
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center;
|
||||
|
||||
.mode-icon {
|
||||
@apply text-white mb-2;
|
||||
}
|
||||
|
||||
.mode-text {
|
||||
@apply text-white text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-slider {
|
||||
:deep(.n-slider) {
|
||||
--n-rail-height: 4px;
|
||||
--n-rail-color: rgba(255, 255, 255, 0.2);
|
||||
--n-fill-color: #10b981;
|
||||
--n-handle-size: 12px;
|
||||
--n-handle-color: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply mb-4;
|
||||
|
||||
.progress-rail {
|
||||
@apply relative w-full h-1 bg-gray-600;
|
||||
|
||||
.progress-buffer {
|
||||
@apply absolute top-0 left-0 h-full bg-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
.volume-slider {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cursor-hidden {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.title-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<!-- 歌单分类列表 -->
|
||||
<div class="play-list-type">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
|
||||
<div>
|
||||
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
|
||||
<span
|
||||
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
|
||||
class="play-list-type-item"
|
||||
:class="
|
||||
setAnimationClass(
|
||||
index <= 19
|
||||
? 'animate__bounceIn'
|
||||
: !isShowAllPlaylistCategory
|
||||
? 'animate__backOutLeft'
|
||||
: 'animate__bounceIn'
|
||||
) +
|
||||
' ' +
|
||||
'type-item-' +
|
||||
index
|
||||
"
|
||||
:style="getAnimationDelay(index)"
|
||||
@click="handleClickPlaylistType(item.name)"
|
||||
>{{ item.name }}</span
|
||||
>
|
||||
</template>
|
||||
<div
|
||||
class="play-list-type-showall"
|
||||
:class="setAnimationClass('animate__bounceIn')"
|
||||
:style="
|
||||
setAnimationDelay(
|
||||
!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30
|
||||
)
|
||||
"
|
||||
@click="handleToggleShowAllPlaylistCategory"
|
||||
>
|
||||
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPlaylistCategory } from '@/api/home';
|
||||
import type { IPlayListSort } from '@/type/playlist';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
// 歌单分类
|
||||
const playlistCategory = ref<IPlayListSort>();
|
||||
// 是否显示全部歌单分类
|
||||
const isShowAllPlaylistCategory = ref<boolean>(false);
|
||||
const DELAY_TIME = 40;
|
||||
const getAnimationDelay = computed(() => {
|
||||
return (index: number) => {
|
||||
if (index <= 19) {
|
||||
return setAnimationDelay(index, DELAY_TIME);
|
||||
}
|
||||
if (!isShowAllPlaylistCategory.value) {
|
||||
const nowIndex = (playlistCategory.value?.sub.length || 0) - index;
|
||||
return setAnimationDelay(nowIndex, DELAY_TIME);
|
||||
}
|
||||
return setAnimationDelay(index - 19, DELAY_TIME);
|
||||
};
|
||||
});
|
||||
|
||||
watch(isShowAllPlaylistCategory, (newVal) => {
|
||||
if (!newVal) {
|
||||
const elements = playlistCategory.value?.sub.map((_, index) =>
|
||||
document.querySelector(`.type-item-${index}`)
|
||||
) as HTMLElement[];
|
||||
elements
|
||||
.slice(20)
|
||||
.reverse()
|
||||
.forEach((element, index) => {
|
||||
if (element) {
|
||||
setTimeout(
|
||||
() => {
|
||||
(element as HTMLElement).style.position = 'absolute';
|
||||
},
|
||||
index * DELAY_TIME + 400
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
isHiding.value = false;
|
||||
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||
if (element) {
|
||||
console.log('element', element);
|
||||
(element as HTMLElement).style.position = 'none';
|
||||
}
|
||||
});
|
||||
},
|
||||
(playlistCategory.value?.sub.length || 0 - 19) * DELAY_TIME
|
||||
);
|
||||
} else {
|
||||
document.querySelectorAll('.play-list-type-item').forEach((element) => {
|
||||
if (element) {
|
||||
(element as HTMLElement).style.position = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 加载歌单分类
|
||||
const loadPlaylistCategory = async () => {
|
||||
const { data } = await getPlaylistCategory();
|
||||
playlistCategory.value = data;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const handleClickPlaylistType = (type: string) => {
|
||||
router.push({
|
||||
path: '/list',
|
||||
query: {
|
||||
type
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isHiding = ref<boolean>(false);
|
||||
const handleToggleShowAllPlaylistCategory = () => {
|
||||
isShowAllPlaylistCategory.value = !isShowAllPlaylistCategory.value;
|
||||
if (!isShowAllPlaylistCategory.value) {
|
||||
isHiding.value = true;
|
||||
}
|
||||
};
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadPlaylistCategory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
.play-list-type {
|
||||
width: 250px;
|
||||
@apply mr-4;
|
||||
&-item,
|
||||
&-showall {
|
||||
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
|
||||
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-200 dark:border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 hover:text-white transition;
|
||||
}
|
||||
&-showall {
|
||||
@apply block text-center;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.play-list-type {
|
||||
@apply mx-0 w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="recommend-album">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
|
||||
<div class="recommend-album-list">
|
||||
<template v-for="(item, index) in albumData?.albums" :key="item.id">
|
||||
<div
|
||||
v-if="index < 6"
|
||||
class="recommend-album-list-item"
|
||||
:class="setAnimationClass('animate__backInUp')"
|
||||
:style="setAnimationDelay(index, 100)"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<n-image
|
||||
class="recommend-album-list-item-img"
|
||||
:src="getImgUrl(item.blurPicUrl, '200y200')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div class="recommend-album-list-item-content">{{ item.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<MusicList
|
||||
v-model:show="showMusic"
|
||||
:name="albumName"
|
||||
:song-list="songList"
|
||||
:cover="false"
|
||||
:loading="loadingList"
|
||||
:list-info="albumInfo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { getNewAlbum } from '@/api/home';
|
||||
import { getAlbum } from '@/api/list';
|
||||
import type { IAlbumNew } from '@/type/album';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
|
||||
const albumData = ref<IAlbumNew>();
|
||||
const loadAlbumList = async () => {
|
||||
const { data } = await getNewAlbum();
|
||||
albumData.value = data;
|
||||
};
|
||||
|
||||
const showMusic = ref(false);
|
||||
const songList = ref([]);
|
||||
const albumName = ref('');
|
||||
const loadingList = ref(false);
|
||||
const albumInfo = ref<any>({});
|
||||
const handleClick = async (item: any) => {
|
||||
songList.value = [];
|
||||
albumInfo.value = {};
|
||||
albumName.value = item.name;
|
||||
loadingList.value = true;
|
||||
showMusic.value = true;
|
||||
const res = await getAlbum(item.id);
|
||||
songList.value = res.data.songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || item.picUrl;
|
||||
return song;
|
||||
});
|
||||
albumInfo.value = {
|
||||
...res.data.album,
|
||||
creator: {
|
||||
avatarUrl: res.data.album.artist.img1v1Url,
|
||||
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
|
||||
},
|
||||
description: res.data.album.description
|
||||
};
|
||||
loadingList.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadAlbumList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-album {
|
||||
@apply flex-1 mx-5;
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.recommend-album-list {
|
||||
@apply grid grid-cols-2 grid-rows-3 gap-2;
|
||||
&-item {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
&-img {
|
||||
@apply rounded-xl transition w-full h-full;
|
||||
}
|
||||
&:hover img {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
&-content {
|
||||
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl text-white bg-opacity-60 bg-black dark:bg-opacity-60 dark:bg-black;
|
||||
}
|
||||
&-content:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<!-- 推荐歌手 -->
|
||||
<n-scrollbar :size="100" :x-scrollable="true">
|
||||
<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)"
|
||||
>
|
||||
<div
|
||||
:style="
|
||||
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
|
||||
"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div
|
||||
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
|
||||
@click="showMusic = true"
|
||||
>
|
||||
<div class="font-bold text-xl">每日推荐</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<p
|
||||
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
|
||||
:key="item.id"
|
||||
class="text-el"
|
||||
>
|
||||
{{ item.name }}
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in hotSingerData?.artists"
|
||||
:key="item.id"
|
||||
class="recommend-singer-item relative"
|
||||
:class="setAnimationClass('animate__backInRight')"
|
||||
:style="setAnimationDelay(index + 1, 100)"
|
||||
>
|
||||
<div
|
||||
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))"
|
||||
class="recommend-singer-item-bg"
|
||||
></div>
|
||||
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
|
||||
{{ item.musicSize }}首
|
||||
</div>
|
||||
<div class="recommend-singer-item-info z-10">
|
||||
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
|
||||
<i class="iconfont icon-playfill text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
|
||||
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<music-list
|
||||
v-if="dayRecommendData?.dailySongs.length"
|
||||
v-model:show="showMusic"
|
||||
name="每日推荐列表"
|
||||
:song-list="dayRecommendData?.dailySongs"
|
||||
:cover="false"
|
||||
/>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getDayRecommend, getHotSinger } from '@/api/home';
|
||||
import router from '@/router';
|
||||
import { IDayRecommend } from '@/type/day_recommend';
|
||||
import type { IHotSinger } from '@/type/singer';
|
||||
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
|
||||
import MusicList from '@/components/MusicList.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// 歌手信息
|
||||
const hotSingerData = ref<IHotSinger>();
|
||||
const dayRecommendData = ref<IDayRecommend>();
|
||||
const showMusic = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// 第一个请求:获取热门歌手
|
||||
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
|
||||
|
||||
// 第二个请求:获取每日推荐
|
||||
try {
|
||||
const {
|
||||
data: { data: dayRecommend }
|
||||
} = await getDayRecommend();
|
||||
// 处理数据
|
||||
if (dayRecommend) {
|
||||
singerData.artists = singerData.artists.slice(0, 4);
|
||||
}
|
||||
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
hotSingerData.value = singerData;
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toSearchSinger = (keyword: string) => {
|
||||
router.push({
|
||||
path: '/search',
|
||||
query: {
|
||||
keyword
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听登录状态
|
||||
watchEffect(() => {
|
||||
if (store.state.user) {
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.recommend-singer {
|
||||
&-list {
|
||||
@apply flex;
|
||||
height: 280px;
|
||||
}
|
||||
&-item {
|
||||
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between overflow-hidden;
|
||||
&-bg {
|
||||
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
|
||||
filter: brightness(60%);
|
||||
}
|
||||
&-info {
|
||||
@apply flex items-center p-2;
|
||||
&-play {
|
||||
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer text-white;
|
||||
}
|
||||
&-name {
|
||||
@apply text-gray-100 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
&-count {
|
||||
@apply text-gray-100 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile .recommend-singer {
|
||||
&-list {
|
||||
height: 180px;
|
||||
@apply ml-4;
|
||||
}
|
||||
&-item {
|
||||
@apply p-4 rounded-xl;
|
||||
&-bg {
|
||||
@apply rounded-xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="recommend-music">
|
||||
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
|
||||
<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)"
|
||||
>
|
||||
<song-item :item="item" @play="handlePlay" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getRecommendMusic } from '@/api/home';
|
||||
import type { IRecommendMusic } from '@/type/music';
|
||||
import { setAnimationClass, setAnimationDelay } from '@/utils';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadRecommendMusic();
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
store.commit('setPlayList', recommendMusic.value?.result);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
|
||||
}
|
||||
.recommend-music {
|
||||
@apply flex-auto;
|
||||
.text-ellipsis {
|
||||
width: 100%;
|
||||
}
|
||||
&-list {
|
||||
@apply rounded-3xl p-2 w-full border border-gray-200 dark:border-gray-700 bg-light dark:bg-black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="dialog"
|
||||
:show-icon="false"
|
||||
:mask-closable="true"
|
||||
class="install-app-modal"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="app-icon">
|
||||
<img src="@/assets/logo.png" alt="App Icon" />
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<h2 class="app-name">Alger Music Player {{ config.version }}</h2>
|
||||
<p class="app-desc mb-2">在桌面安装应用,获得更好的体验</p>
|
||||
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
|
||||
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
|
||||
</div>
|
||||
<div class="modal-desc mt-4 text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
下载遇到问题?去
|
||||
<a
|
||||
class="text-green-500"
|
||||
target="_blank"
|
||||
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
|
||||
>GitHub</a
|
||||
>
|
||||
下载最新版本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { isElectron, isMobile } from '@/utils';
|
||||
|
||||
import config from '../../../../package.json';
|
||||
|
||||
const showModal = ref(false);
|
||||
const noPrompt = ref(false);
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
if (noPrompt.value) {
|
||||
localStorage.setItem('installPromptDismissed', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 如果是 electron 环境,不显示安装提示
|
||||
if (isElectron || isMobile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经点击过"暂不安装"
|
||||
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
|
||||
if (isDismissed) {
|
||||
return;
|
||||
}
|
||||
showModal.value = true;
|
||||
});
|
||||
|
||||
const handleInstall = async (): Promise<void> => {
|
||||
const { userAgent } = navigator;
|
||||
console.log('userAgent', userAgent);
|
||||
const isMac: boolean = userAgent.includes('Mac');
|
||||
const isWindows: boolean = userAgent.includes('Win');
|
||||
const isARM: boolean =
|
||||
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
|
||||
const isX64: boolean =
|
||||
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
|
||||
const isX86: boolean =
|
||||
!isX64 &&
|
||||
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
|
||||
|
||||
const getDownloadUrl = (os: string, arch: string): string => {
|
||||
const version = config.version as string;
|
||||
const setup = os !== 'mac' ? 'Setup_' : '';
|
||||
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
|
||||
};
|
||||
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
|
||||
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
|
||||
|
||||
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
|
||||
|
||||
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.install-app-modal {
|
||||
:deep(.n-modal) {
|
||||
@apply max-w-sm;
|
||||
}
|
||||
.modal-content {
|
||||
@apply p-4 pb-0;
|
||||
.modal-header {
|
||||
@apply flex items-center mb-6;
|
||||
.app-icon {
|
||||
@apply w-20 h-20 mr-4 rounded-2xl overflow-hidden;
|
||||
img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
.app-info {
|
||||
@apply flex-1;
|
||||
.app-name {
|
||||
@apply text-xl font-bold mb-1;
|
||||
}
|
||||
.app-desc {
|
||||
@apply text-sm text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal-actions {
|
||||
@apply flex gap-3 mt-4;
|
||||
.n-button {
|
||||
@apply flex-1;
|
||||
}
|
||||
.cancel-btn {
|
||||
@apply bg-gray-800 text-gray-300 border-none;
|
||||
&:hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
.install-btn {
|
||||
@apply bg-green-600 border-none;
|
||||
&:hover {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { setAnimationClass } from '@/utils';
|
||||
|
||||
const props = defineProps({
|
||||
showPop: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const musicFullClass = computed(() => {
|
||||
if (props.showPop) {
|
||||
return setAnimationClass('animate__fadeInUp');
|
||||
}
|
||||
return setAnimationClass('animate__fadeOutDown');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
|
||||
<i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
|
||||
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pop-page {
|
||||
height: 800px;
|
||||
@apply absolute top-4 left-0 w-full;
|
||||
background-color: #000000f0;
|
||||
.close {
|
||||
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div v-if="isPlay" class="bottom" :style="{ height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const store = useStore();
|
||||
const isPlay = computed(() => store.state.isPlay as boolean);
|
||||
defineProps({
|
||||
height: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bottom {
|
||||
@apply h-28;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="search-item" :class="item.type" @click="handleClick">
|
||||
<div class="search-item-img">
|
||||
<n-image
|
||||
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '100y100')"
|
||||
lazy
|
||||
preview-disabled
|
||||
/>
|
||||
<div v-if="item.type === 'mv'" class="play">
|
||||
<i class="iconfont icon icon-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-item-info">
|
||||
<p class="search-item-name">{{ item.name }}</p>
|
||||
<p class="search-item-artist">{{ item.desc }}</p>
|
||||
</div>
|
||||
|
||||
<MusicList
|
||||
v-if="['专辑', 'playlist'].includes(item.type)"
|
||||
v-model:show="showPop"
|
||||
:name="item.name"
|
||||
:song-list="songList"
|
||||
:list-info="listInfo"
|
||||
:cover="false"
|
||||
/>
|
||||
<mv-player
|
||||
v-if="item.type === 'mv'"
|
||||
v-model:show="showPop"
|
||||
:current-mv="getCurrentMv()"
|
||||
no-list
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { getAlbum, getListDetail } from '@/api/list';
|
||||
import MvPlayer from '@/components/MvPlayer.vue';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { IMvItem } from '@/type/mv';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import MusicList from '../MusicList.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
item: {
|
||||
picUrl: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}>();
|
||||
|
||||
const songList = ref<any[]>([]);
|
||||
|
||||
const showPop = ref(false);
|
||||
const listInfo = ref<any>(null);
|
||||
|
||||
const getCurrentMv = () => {
|
||||
return {
|
||||
id: props.item.id,
|
||||
name: props.item.name
|
||||
} as unknown as IMvItem;
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const handleClick = async () => {
|
||||
listInfo.value = null;
|
||||
if (props.item.type === '专辑') {
|
||||
showPop.value = true;
|
||||
const res = await getAlbum(props.item.id);
|
||||
songList.value = res.data.songs.map((song: any) => {
|
||||
song.al.picUrl = song.al.picUrl || props.item.picUrl;
|
||||
return song;
|
||||
});
|
||||
listInfo.value = {
|
||||
...res.data.album,
|
||||
creator: {
|
||||
avatarUrl: res.data.album.artist.img1v1Url,
|
||||
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
|
||||
},
|
||||
description: res.data.album.description
|
||||
};
|
||||
}
|
||||
|
||||
if (props.item.type === 'playlist') {
|
||||
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') {
|
||||
store.commit('setIsPlay', false);
|
||||
store.commit('setPlayMusic', false);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
showPop.value = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-item {
|
||||
@apply rounded-3xl p-3 flex items-center hover:bg-light-200 dark:hover:bg-gray-800 transition cursor-pointer;
|
||||
margin: 0 10px;
|
||||
.search-item-img {
|
||||
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
|
||||
}
|
||||
.search-item-info {
|
||||
@apply flex-1 overflow-hidden;
|
||||
&-name {
|
||||
@apply text-white text-sm text-center;
|
||||
}
|
||||
&-artist {
|
||||
@apply text-gray-400 text-xs text-center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mv {
|
||||
&:hover {
|
||||
.play {
|
||||
@apply opacity-60;
|
||||
}
|
||||
}
|
||||
.search-item-img {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
@apply rounded-lg relative;
|
||||
}
|
||||
.play {
|
||||
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity;
|
||||
.icon {
|
||||
@apply text-white text-5xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
|
||||
<n-image
|
||||
v-if="item.picUrl"
|
||||
ref="songImg"
|
||||
:src="getImgUrl(item.picUrl, '100y100')"
|
||||
class="song-item-img"
|
||||
preview-disabled
|
||||
:img-props="{
|
||||
crossorigin: 'anonymous'
|
||||
}"
|
||||
@load="imageLoad"
|
||||
/>
|
||||
<div class="song-item-content">
|
||||
<div v-if="list" class="song-item-content-wrapper">
|
||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{
|
||||
item.name
|
||||
}}</n-ellipsis>
|
||||
<div class="song-item-content-divider">-</div>
|
||||
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
||||
<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>
|
||||
<template v-else>
|
||||
<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.ar || item.song.artists"
|
||||
:key="artistsindex"
|
||||
>{{ artists.name
|
||||
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
|
||||
>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
|
||||
<div v-if="favorite" class="song-item-operating-like">
|
||||
<i
|
||||
class="iconfont icon-likefill"
|
||||
:class="{ 'like-active': isFavorite }"
|
||||
@click.stop="toggleFavorite"
|
||||
></i>
|
||||
</div>
|
||||
<div
|
||||
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
|
||||
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
|
||||
@click="playMusicEvent(item)"
|
||||
>
|
||||
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
||||
<i v-else class="iconfont icon-playfill"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
import { audioService } from '@/services/audioService';
|
||||
import type { SongResult } from '@/type/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageBackground } from '@/utils/linearColor';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
item: SongResult;
|
||||
mini?: boolean;
|
||||
list?: boolean;
|
||||
favorite?: boolean;
|
||||
}>(),
|
||||
{
|
||||
mini: false,
|
||||
list: false,
|
||||
favorite: true
|
||||
}
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const play = computed(() => store.state.play as boolean);
|
||||
|
||||
const playMusic = computed(() => store.state.playMusic);
|
||||
|
||||
const playLoading = computed(
|
||||
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
|
||||
);
|
||||
|
||||
// 判断是否为正在播放的音乐
|
||||
const isPlaying = computed(() => {
|
||||
return playMusic.value.id === props.item.id;
|
||||
});
|
||||
|
||||
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);
|
||||
audioService.getCurrentSound()?.pause();
|
||||
} else {
|
||||
store.commit('setPlayMusic', true);
|
||||
audioService.getCurrentSound()?.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await store.commit('setPlay', item);
|
||||
store.commit('setIsPlay', true);
|
||||
emits('play', item);
|
||||
};
|
||||
|
||||
// 判断是否已收藏
|
||||
const isFavorite = computed(() => {
|
||||
return store.state.favoriteList.includes(props.item.id);
|
||||
});
|
||||
|
||||
// 切换收藏状态
|
||||
const toggleFavorite = async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
if (isFavorite.value) {
|
||||
store.commit('removeFromFavorite', props.item.id);
|
||||
} else {
|
||||
store.commit('addToFavorite', props.item.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 配置文字不可选中
|
||||
.text-ellipsis {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
&-img {
|
||||
@apply w-12 h-12 rounded-2xl mr-4;
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply flex-1;
|
||||
|
||||
&-title {
|
||||
@apply text-base text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
&-operating {
|
||||
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
|
||||
|
||||
.iconfont {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.icon-likefill {
|
||||
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
|
||||
}
|
||||
|
||||
&-like {
|
||||
@apply mr-2 cursor-pointer ml-4;
|
||||
}
|
||||
|
||||
.like-active {
|
||||
@apply text-red-500;
|
||||
}
|
||||
|
||||
&-play {
|
||||
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
|
||||
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
|
||||
|
||||
&:hover,
|
||||
&.bg-green-600 {
|
||||
@apply bg-green-500 border-green-500 text-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.song-mini {
|
||||
@apply p-2 rounded-2xl;
|
||||
|
||||
.song-item {
|
||||
@apply p-0;
|
||||
|
||||
&-img {
|
||||
@apply w-10 h-10 mr-2;
|
||||
}
|
||||
|
||||
&-content {
|
||||
@apply flex-1;
|
||||
|
||||
&-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
&-operating {
|
||||
@apply pl-2;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
&-like {
|
||||
@apply mr-1 ml-1;
|
||||
}
|
||||
|
||||
&-play {
|
||||
@apply w-8 h-8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.song-list {
|
||||
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-50 dark:bg-gray-800;
|
||||
}
|
||||
|
||||
.song-item-img {
|
||||
@apply w-10 h-10 rounded-lg mr-3;
|
||||
}
|
||||
|
||||
.song-item-content {
|
||||
@apply flex items-center flex-1;
|
||||
|
||||
&-wrapper {
|
||||
@apply flex items-center flex-1 text-sm;
|
||||
}
|
||||
|
||||
&-title {
|
||||
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-divider {
|
||||
@apply mx-2 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
&-name {
|
||||
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.song-item-operating {
|
||||
@apply flex items-center gap-2;
|
||||
|
||||
&-like {
|
||||
@apply cursor-pointer hover:scale-110 transition-transform;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
|
||||
}
|
||||
}
|
||||
|
||||
&-play {
|
||||
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
|
||||
|
||||
.iconfont {
|
||||
@apply text-base;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="dialog"
|
||||
:show-icon="false"
|
||||
:mask-closable="true"
|
||||
class="update-app-modal"
|
||||
style="width: 800px; max-width: 90vw"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="app-icon">
|
||||
<img src="@/assets/logo.png" alt="App Icon" />
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<h2 class="app-name">发现新版本 {{ updateInfo.latestVersion }}</h2>
|
||||
<p class="app-desc mb-2">当前版本 {{ updateInfo.currentVersion }}</p>
|
||||
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-info">
|
||||
<div class="update-title">更新内容:</div>
|
||||
<n-scrollbar style="max-height: 300px">
|
||||
<div class="update-body" v-html="parsedReleaseNotes"></div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<n-button class="cancel-btn" @click="closeModal">暂不更新</n-button>
|
||||
<n-button type="primary" class="update-btn" @click="handleUpdate">立即更新</n-button>
|
||||
</div>
|
||||
<div class="modal-desc mt-4 text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
下载遇到问题?去
|
||||
<a
|
||||
class="text-green-500"
|
||||
target="_blank"
|
||||
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
|
||||
>GitHub</a
|
||||
>
|
||||
下载最新版本
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { checkUpdate } from '@/utils';
|
||||
import config from '../../../../package.json';
|
||||
|
||||
// 配置 marked
|
||||
marked.setOptions({
|
||||
breaks: true, // 支持 GitHub 风格的换行
|
||||
gfm: true // 启用 GitHub 风格的 Markdown
|
||||
});
|
||||
|
||||
interface ReleaseInfo {
|
||||
tag_name: string;
|
||||
body?: string;
|
||||
html_url: string;
|
||||
assets: Array<{
|
||||
browser_download_url: string;
|
||||
name: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const showModal = ref(false);
|
||||
const noPrompt = ref(false);
|
||||
const updateInfo = ref({
|
||||
hasUpdate: false,
|
||||
latestVersion: '',
|
||||
currentVersion: config.version,
|
||||
releaseInfo: null as ReleaseInfo | null
|
||||
});
|
||||
|
||||
// 解析 Markdown
|
||||
const parsedReleaseNotes = computed(() => {
|
||||
if (!updateInfo.value.releaseInfo?.body) return '';
|
||||
try {
|
||||
return marked.parse(updateInfo.value.releaseInfo.body);
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error);
|
||||
return updateInfo.value.releaseInfo.body;
|
||||
}
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
if (noPrompt.value) {
|
||||
localStorage.setItem('updatePromptDismissed', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const result = await checkUpdate();
|
||||
updateInfo.value = result;
|
||||
// 如果有更新且用户没有选择不再提示,则显示弹窗
|
||||
if (result.hasUpdate && localStorage.getItem('updatePromptDismissed') !== 'true') {
|
||||
showModal.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查更新失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const { userAgent } = navigator;
|
||||
const isMac: boolean = userAgent.includes('Mac');
|
||||
const isWindows: boolean = userAgent.includes('Win');
|
||||
const isARM: boolean =
|
||||
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
|
||||
const isX64: boolean =
|
||||
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
|
||||
const isX86: boolean =
|
||||
!isX64 &&
|
||||
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
|
||||
|
||||
const getDownloadUrl = (os: string, arch: string): string => {
|
||||
const version = updateInfo.value.latestVersion;
|
||||
const setup = os !== 'mac' ? 'Setup_' : '';
|
||||
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
|
||||
};
|
||||
|
||||
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
|
||||
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
|
||||
|
||||
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
|
||||
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
|
||||
closeModal();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.update-app-modal {
|
||||
:deep(.n-modal) {
|
||||
@apply max-w-4xl;
|
||||
}
|
||||
.modal-content {
|
||||
@apply p-6 pb-4;
|
||||
.modal-header {
|
||||
@apply flex items-center mb-6;
|
||||
.app-icon {
|
||||
@apply w-24 h-24 mr-6 rounded-2xl overflow-hidden;
|
||||
img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
}
|
||||
.app-info {
|
||||
@apply flex-1;
|
||||
.app-name {
|
||||
@apply text-2xl font-bold mb-2;
|
||||
}
|
||||
.app-desc {
|
||||
@apply text-base text-gray-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
.update-info {
|
||||
@apply mb-6 rounded-lg bg-gray-50 dark:bg-gray-800;
|
||||
.update-title {
|
||||
@apply text-base font-medium p-4 pb-2;
|
||||
}
|
||||
.update-body {
|
||||
@apply p-4 pt-2 text-gray-600 dark:text-gray-300;
|
||||
|
||||
:deep(h1) {
|
||||
@apply text-xl font-bold mb-3;
|
||||
}
|
||||
:deep(h2) {
|
||||
@apply text-lg font-bold mb-3;
|
||||
}
|
||||
:deep(h3) {
|
||||
@apply text-base font-bold mb-2;
|
||||
}
|
||||
:deep(p) {
|
||||
@apply mb-3 leading-relaxed;
|
||||
}
|
||||
:deep(ul) {
|
||||
@apply list-disc list-inside mb-3;
|
||||
}
|
||||
:deep(ol) {
|
||||
@apply list-decimal list-inside mb-3;
|
||||
}
|
||||
:deep(li) {
|
||||
@apply mb-2 leading-relaxed;
|
||||
}
|
||||
:deep(code) {
|
||||
@apply px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
:deep(pre) {
|
||||
@apply p-3 rounded bg-gray-100 dark:bg-gray-700 overflow-x-auto mb-3;
|
||||
code {
|
||||
@apply bg-transparent p-0;
|
||||
}
|
||||
}
|
||||
:deep(blockquote) {
|
||||
@apply pl-4 border-l-4 border-gray-200 dark:border-gray-600 mb-3;
|
||||
}
|
||||
:deep(a) {
|
||||
@apply text-green-500 hover:text-green-600 dark:hover:text-green-400;
|
||||
}
|
||||
:deep(hr) {
|
||||
@apply my-4 border-gray-200 dark:border-gray-600;
|
||||
}
|
||||
:deep(table) {
|
||||
@apply w-full mb-3;
|
||||
th, td {
|
||||
@apply px-3 py-2 border border-gray-200 dark:border-gray-600;
|
||||
}
|
||||
th {
|
||||
@apply bg-gray-100 dark:bg-gray-700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal-actions {
|
||||
@apply flex gap-4 mt-6;
|
||||
.n-button {
|
||||
@apply flex-1 text-base py-2;
|
||||
}
|
||||
.cancel-btn {
|
||||
@apply bg-gray-800 text-gray-300 border-none;
|
||||
&:hover {
|
||||
@apply bg-gray-700;
|
||||
}
|
||||
}
|
||||
.update-btn {
|
||||
@apply bg-green-600 border-none;
|
||||
&:hover {
|
||||
@apply bg-green-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user