mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-24 16:27:23 +08:00
🦄 refactor: 重构歌曲组件,添加基础组件和多种样式,优化播放列表抽屉功能
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
:width="400"
|
:width="400"
|
||||||
placement="right"
|
placement="right"
|
||||||
@update:show="$emit('update:modelValue', $event)"
|
@update:show="$emit('update:modelValue', $event)"
|
||||||
|
:unstable-show-mask="false"
|
||||||
>
|
>
|
||||||
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
|
<n-drawer-content :title="t('comp.playlistDrawer.title')" class="mac-style-drawer">
|
||||||
<n-scrollbar class="h-full">
|
<n-scrollbar class="h-full">
|
||||||
|
|||||||
@@ -1,150 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<component
|
||||||
class="song-item"
|
:is="renderComponent"
|
||||||
:class="{ 'song-mini': mini, 'song-list': list, 'song-compact': compact }"
|
:item="item"
|
||||||
@contextmenu.prevent="handleContextMenu"
|
:favorite="favorite"
|
||||||
@mouseenter="handleMouseEnter"
|
:selectable="selectable"
|
||||||
@mouseleave="handleMouseLeave"
|
:selected="selected"
|
||||||
@dblclick.stop="playMusicEvent(item)"
|
:can-remove="canRemove"
|
||||||
>
|
:is-next="isNext"
|
||||||
<div v-if="compact && index !== undefined" class="song-item-index" :class="{ 'text-green-500': isPlaying }">
|
:index="index"
|
||||||
{{ index + 1 }}
|
@play="$emit('play', $event)"
|
||||||
</div>
|
@select="$emit('select', $event)"
|
||||||
<div v-if="selectable" class="song-item-select" @click.stop="toggleSelect">
|
@remove-song="$emit('remove-song', $event)"
|
||||||
<n-checkbox :checked="selected" />
|
|
||||||
</div>
|
|
||||||
<n-image
|
|
||||||
v-if="item.picUrl && !compact"
|
|
||||||
ref="songImg"
|
|
||||||
:src="getImgUrl(item.picUrl, '100y100')"
|
|
||||||
class="song-item-img"
|
|
||||||
preview-disabled
|
|
||||||
:img-props="{
|
|
||||||
crossorigin: 'anonymous'
|
|
||||||
}"
|
|
||||||
@load="imageLoad"
|
|
||||||
/>
|
/>
|
||||||
<div class="song-item-content" :class="{ 'song-item-content-compact': compact }">
|
|
||||||
<div v-if="list" class="song-item-content-wrapper">
|
|
||||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{
|
|
||||||
item.name
|
|
||||||
}}</n-ellipsis>
|
|
||||||
<div class="song-item-content-divider">-</div>
|
|
||||||
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
|
||||||
<template v-for="(artist, index) in artists" :key="index">
|
|
||||||
<span
|
|
||||||
class="cursor-pointer hover:text-green-500"
|
|
||||||
@click.stop="handleArtistClick(artist.id)"
|
|
||||||
>{{ artist.name }}</span
|
|
||||||
>
|
|
||||||
<span v-if="index < artists.length - 1"> / </span>
|
|
||||||
</template>
|
|
||||||
</n-ellipsis>
|
|
||||||
</div>
|
|
||||||
<template v-else-if="compact">
|
|
||||||
<div class="song-item-content-compact-wrapper">
|
|
||||||
<div class="w-60 flex-shrink-0 flex items-center">
|
|
||||||
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
|
|
||||||
{{ item.name }}
|
|
||||||
</n-ellipsis>
|
|
||||||
</div>
|
|
||||||
<div class="w-40 flex-shrink-0 song-item-content-compact-artist flex items-center">
|
|
||||||
<n-ellipsis line-clamp="1">
|
|
||||||
<template v-for="(artist, index) in artists" :key="index">
|
|
||||||
<span
|
|
||||||
class="cursor-pointer hover:text-green-500"
|
|
||||||
@click.stop="handleArtistClick(artist.id)"
|
|
||||||
>{{ artist.name }}</span
|
|
||||||
>
|
|
||||||
<span v-if="index < artists.length - 1"> / </span>
|
|
||||||
</template>
|
|
||||||
</n-ellipsis>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="song-item-content-album flex items-center">
|
|
||||||
<n-ellipsis line-clamp="1">{{ item.al?.name || '-' }}</n-ellipsis>
|
|
||||||
</div>
|
|
||||||
<div class="song-item-content-duration flex items-center">
|
|
||||||
{{ formatDuration(getDuration(item)) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="song-item-content-title">
|
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{ item.name }}</n-ellipsis>
|
|
||||||
</div>
|
|
||||||
<div class="song-item-content-name">
|
|
||||||
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
|
||||||
<template v-for="(artist, index) in artists" :key="index">
|
|
||||||
<span
|
|
||||||
class="cursor-pointer hover:text-green-500"
|
|
||||||
@click.stop="handleArtistClick(artist.id)"
|
|
||||||
>{{ artist.name }}</span
|
|
||||||
>
|
|
||||||
<span v-if="index < artists.length - 1"> / </span>
|
|
||||||
</template>
|
|
||||||
</n-ellipsis>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div class="song-item-operating" :class="{
|
|
||||||
'song-item-operating-list': list,
|
|
||||||
'song-item-operating-compact': compact
|
|
||||||
}">
|
|
||||||
<div v-if="favorite" class="song-item-operating-like" :class="{ 'opacity-0': compact && !isHovering && !isFavorite }">
|
|
||||||
<i
|
|
||||||
class="iconfont icon-likefill"
|
|
||||||
:class="{ 'like-active': isFavorite }"
|
|
||||||
@click.stop="toggleFavorite"
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
<n-tooltip v-if="isNext" trigger="hover" :z-index="9999999" :delay="400">
|
|
||||||
<template #trigger>
|
|
||||||
<div class="song-item-operating-next" @click.stop="handlePlayNext">
|
|
||||||
<i class="iconfont ri-skip-forward-fill"></i>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
{{ t('songItem.menu.playNext') }}
|
|
||||||
</n-tooltip>
|
|
||||||
<div
|
|
||||||
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
|
|
||||||
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading, 'opacity-0': compact && !isHovering && !isPlaying }"
|
|
||||||
@click="playMusicEvent(item)"
|
|
||||||
>
|
|
||||||
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
|
||||||
<i v-else class="iconfont icon-playfill"></i>
|
|
||||||
</div>
|
|
||||||
<div v-if="compact" class="song-item-operating-menu" @click.stop="handleMenuClick" :class="{ 'opacity-0': compact && !isHovering && !isPlaying }">
|
|
||||||
<i class="iconfont ri-more-fill"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-dropdown
|
|
||||||
v-if="isElectron"
|
|
||||||
:show="showDropdown"
|
|
||||||
:x="dropdownX"
|
|
||||||
:y="dropdownY"
|
|
||||||
:options="dropdownOptions"
|
|
||||||
:z-index="99999999"
|
|
||||||
placement="bottom-start"
|
|
||||||
@clickoutside="showDropdown = false"
|
|
||||||
@select="handleSelect"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { MenuOption } from 'naive-ui';
|
import { computed } from 'vue';
|
||||||
import { NEllipsis, NImage, useMessage, useDialog } from 'naive-ui';
|
|
||||||
import { computed, h, inject, ref, useTemplateRef } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import { useArtist } from '@/hooks/useArtist';
|
|
||||||
import { usePlayerStore } from '@/store';
|
|
||||||
import type { SongResult } from '@/type/music';
|
import type { SongResult } from '@/type/music';
|
||||||
import { getImgUrl, isElectron } from '@/utils';
|
|
||||||
import { getImageBackground } from '@/utils/linearColor';
|
|
||||||
import { useDownload } from '@/hooks/useDownload';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
import StandardSongItem from './songItemCom/StandardSongItem.vue';
|
||||||
|
import MiniSongItem from './songItemCom/MiniSongItem.vue';
|
||||||
|
import ListSongItem from './songItemCom/ListSongItem.vue';
|
||||||
|
import CompactSongItem from './songItemCom/CompactSongItem.vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -172,649 +49,13 @@ const props = withDefaults(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
defineEmits(['play', 'select', 'remove-song']);
|
||||||
|
|
||||||
const message = useMessage();
|
// 根据属性决定渲染哪个组件
|
||||||
|
const renderComponent = computed(() => {
|
||||||
const play = computed(() => playerStore.isPlay);
|
if (props.mini) return MiniSongItem;
|
||||||
const playMusic = computed(() => playerStore.playMusic);
|
if (props.list) return ListSongItem;
|
||||||
const playLoading = computed(
|
if (props.compact) return CompactSongItem;
|
||||||
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
|
return StandardSongItem;
|
||||||
);
|
|
||||||
const isPlaying = computed(() => {
|
|
||||||
return playMusic.value.id === props.item.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showDropdown = ref(false);
|
|
||||||
const dropdownX = ref(0);
|
|
||||||
const dropdownY = ref(0);
|
|
||||||
const isHovering = ref(false);
|
|
||||||
|
|
||||||
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
|
|
||||||
|
|
||||||
const { navigateToArtist } = useArtist();
|
|
||||||
|
|
||||||
const renderSongPreview = () => {
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(NImage, {
|
|
||||||
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
|
|
||||||
class: 'w-10 h-10 rounded-lg flex-shrink-0',
|
|
||||||
previewDisabled: true,
|
|
||||||
imgProps: {
|
|
||||||
crossorigin: 'anonymous'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: 'flex-1 min-w-0 py-1 overflow-hidden'
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: 'mb-1 overflow-hidden'
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(
|
|
||||||
NEllipsis,
|
|
||||||
{
|
|
||||||
lineClamp: 1,
|
|
||||||
depth: 1,
|
|
||||||
class: 'text-sm font-medium w-full',
|
|
||||||
style: 'max-width: 150px; min-width: 120px;'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: () => props.item.name
|
|
||||||
}
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{
|
|
||||||
class: 'text-xs text-gray-500 dark:text-gray-400 overflow-hidden'
|
|
||||||
},
|
|
||||||
[
|
|
||||||
h(
|
|
||||||
NEllipsis,
|
|
||||||
{
|
|
||||||
lineClamp: 1,
|
|
||||||
style: 'max-width: 150px;'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: () => {
|
|
||||||
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(' / ');
|
|
||||||
return artistNames || '未知艺术家';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownOptions = computed<MenuOption[]>(() => {
|
|
||||||
const options: MenuOption[] = [
|
|
||||||
{
|
|
||||||
key: 'header',
|
|
||||||
type: 'render',
|
|
||||||
render: renderSongPreview
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'divider1',
|
|
||||||
type: 'divider'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('songItem.menu.play'),
|
|
||||||
key: 'play',
|
|
||||||
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('songItem.menu.playNext'),
|
|
||||||
key: 'playNext',
|
|
||||||
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
key: 'd1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('songItem.menu.download'),
|
|
||||||
key: 'download',
|
|
||||||
icon: () => h('i', { class: 'iconfont ri-download-line' })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('songItem.menu.addToPlaylist'),
|
|
||||||
key: 'addToPlaylist',
|
|
||||||
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: isFavorite.value ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
|
|
||||||
key: 'favorite',
|
|
||||||
icon: () =>
|
|
||||||
h('i', {
|
|
||||||
class: `iconfont ${isFavorite.value ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// 不喜欢
|
|
||||||
{
|
|
||||||
label: isDislike.value ? t('songItem.menu.undislike') : t('songItem.menu.dislike'),
|
|
||||||
key: 'dislike',
|
|
||||||
icon: () => h('i', { class: `iconfont ${isDislike.value ? 'ri-dislike-fill text-green-500': 'ri-dislike-line'}` })
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (props.canRemove) {
|
|
||||||
options.push(
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
key: 'd2'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('songItem.menu.removeFromPlaylist'),
|
|
||||||
key: 'remove',
|
|
||||||
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleContextMenu = (e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showDropdown.value = true;
|
|
||||||
dropdownX.value = e.clientX;
|
|
||||||
dropdownY.value = e.clientY;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMenuClick = (e: MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
showDropdown.value = true;
|
|
||||||
dropdownX.value = e.clientX;
|
|
||||||
dropdownY.value = e.clientY;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (key: string | number) => {
|
|
||||||
showDropdown.value = false;
|
|
||||||
switch (key) {
|
|
||||||
case 'download':
|
|
||||||
downloadMusic(props.item);
|
|
||||||
break;
|
|
||||||
case 'playNext':
|
|
||||||
handlePlayNext();
|
|
||||||
break;
|
|
||||||
case 'addToPlaylist':
|
|
||||||
openPlaylistDrawer?.(props.item.id);
|
|
||||||
break;
|
|
||||||
case 'favorite':
|
|
||||||
toggleFavorite(new Event('click'));
|
|
||||||
break;
|
|
||||||
case 'play':
|
|
||||||
playMusicEvent(props.item);
|
|
||||||
break;
|
|
||||||
case 'remove':
|
|
||||||
emits('remove-song', props.item.id);
|
|
||||||
break;
|
|
||||||
case 'dislike':
|
|
||||||
toggleDislike(new Event('click'));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 下载音乐
|
|
||||||
const { downloadMusic } = useDownload();
|
|
||||||
|
|
||||||
const emits = defineEmits(['play', 'select', 'remove-song']);
|
|
||||||
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) => {
|
|
||||||
try {
|
|
||||||
// 使用store的setPlay方法,该方法已经包含了B站视频URL处理逻辑
|
|
||||||
const result = await playerStore.setPlay(item);
|
|
||||||
if (!result) {
|
|
||||||
throw new Error('播放失败');
|
|
||||||
}
|
|
||||||
emits('play', item);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('播放出错:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 判断是否已收藏
|
|
||||||
const isFavorite = computed(() => {
|
|
||||||
// 将id转换为number,兼容B站视频ID
|
|
||||||
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
|
|
||||||
return playerStore.favoriteList.includes(numericId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDislike = computed(() => {
|
|
||||||
// 将id转换为number,兼容B站视频ID
|
|
||||||
const numericId = typeof props.item.id ==='string'? parseInt(props.item.id, 10) : props.item.id;
|
|
||||||
return playerStore.dislikeList.includes(numericId);
|
|
||||||
})
|
|
||||||
|
|
||||||
// 切换收藏状态
|
|
||||||
const toggleFavorite = async (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// 将id转换为number,兼容B站视频ID
|
|
||||||
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
|
|
||||||
|
|
||||||
if (isFavorite.value) {
|
|
||||||
playerStore.removeFromFavorite(numericId);
|
|
||||||
} else {
|
|
||||||
playerStore.addToFavorite(numericId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const dialog = useDialog();
|
|
||||||
const toggleDislike = async (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isDislike.value) {
|
|
||||||
playerStore.removeFromDislikeList(props.item.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dialog.warning({
|
|
||||||
title: t('songItem.dialog.dislike.title'),
|
|
||||||
content: t('songItem.dialog.dislike.content'),
|
|
||||||
positiveText: t('songItem.dialog.dislike.positiveText'),
|
|
||||||
negativeText: t('songItem.dialog.dislike.negativeText'),
|
|
||||||
onPositiveClick: () => {
|
|
||||||
playerStore.addToDislikeList(props.item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换选择状态
|
|
||||||
const toggleSelect = () => {
|
|
||||||
emits('select', props.item.id, !props.selected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArtistClick = (id: number) => {
|
|
||||||
navigateToArtist(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取歌手列表(最多显示5个)
|
|
||||||
const artists = computed(() => {
|
|
||||||
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加到下一首播放
|
|
||||||
const handlePlayNext = () => {
|
|
||||||
playerStore.addToNextPlay(props.item);
|
|
||||||
message.success(t('songItem.message.addedToNextPlay'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取歌曲时长
|
|
||||||
const getDuration = (item: SongResult): number => {
|
|
||||||
// 检查各种可能的时长属性路径
|
|
||||||
if (item.duration) return item.duration;
|
|
||||||
if (typeof item.dt === 'number') return item.dt;
|
|
||||||
// 遍历可能存在的其他时长属性路径
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化时长
|
|
||||||
const formatDuration = (ms: number): string => {
|
|
||||||
if (!ms) return '--:--';
|
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 鼠标悬停事件
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
isHovering.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
isHovering.value = false;
|
|
||||||
};
|
|
||||||
</script>
|
</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-light-100 dark:bg-dark-100;
|
|
||||||
|
|
||||||
.song-item-operating-compact {
|
|
||||||
.song-item-operating-like,
|
|
||||||
.song-item-operating-play {
|
|
||||||
@apply opacity-100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-img {
|
|
||||||
@apply w-12 h-12 rounded-2xl mr-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-index {
|
|
||||||
@apply w-8 text-center text-gray-500 dark:text-gray-400 text-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-compact {
|
|
||||||
@apply flex items-center gap-4;
|
|
||||||
|
|
||||||
&-wrapper {
|
|
||||||
@apply flex-1 min-w-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-artist {
|
|
||||||
@apply text-sm text-gray-500 dark:text-gray-400 ml-2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-album {
|
|
||||||
@apply w-32 text-sm text-gray-500 dark:text-gray-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-duration {
|
|
||||||
@apply w-16 text-sm text-gray-500 dark:text-gray-400 text-right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-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 transition-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-next {
|
|
||||||
@apply mr-2 cursor-pointer transition-all;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.like-active {
|
|
||||||
@apply text-red-500 dark: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-download {
|
|
||||||
@apply mr-2 cursor-pointer;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-menu {
|
|
||||||
@apply cursor-pointer flex items-center justify-center px-2;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-select {
|
|
||||||
@apply mr-3 cursor-pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-compact {
|
|
||||||
@apply rounded-lg p-2 h-12 mb-1 border-b dark:border-gray-800 border-gray-100;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply bg-gray-50 dark:bg-gray-700;
|
|
||||||
|
|
||||||
.opacity-0 {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-item-content {
|
|
||||||
&-title {
|
|
||||||
@apply text-sm cursor-pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-item-content-compact-wrapper {
|
|
||||||
@apply flex items-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-item-content-compact-artist {
|
|
||||||
@apply w-40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-item-operating-compact {
|
|
||||||
@apply border-none bg-transparent gap-2 flex items-center;
|
|
||||||
|
|
||||||
.song-item-operating-like,
|
|
||||||
.song-item-operating-play {
|
|
||||||
@apply transition-opacity duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-item-operating-play {
|
|
||||||
@apply w-7 h-7;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-item-operating-like {
|
|
||||||
@apply mr-1 ml-0;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.opacity-0 {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.n-dropdown-menu) {
|
|
||||||
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
|
|
||||||
|
|
||||||
.n-dropdown-option {
|
|
||||||
@apply h-9 text-sm;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
@apply bg-gray-100 dark:bg-gray-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.n-dropdown-option-body {
|
|
||||||
@apply h-full;
|
|
||||||
|
|
||||||
.n-dropdown-option-body__prefix {
|
|
||||||
@apply w-8 flex justify-center items-center;
|
|
||||||
|
|
||||||
.iconfont {
|
|
||||||
@apply text-base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.n-dropdown-divider {
|
|
||||||
@apply my-1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.song-preview) {
|
|
||||||
@apply flex items-center gap-3 p-3 border-b dark:border-gray-800;
|
|
||||||
|
|
||||||
.n-image {
|
|
||||||
@apply w-12 h-12 rounded-lg flex-shrink-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-preview-info {
|
|
||||||
@apply flex-1 min-w-0 py-1;
|
|
||||||
|
|
||||||
.song-preview-name {
|
|
||||||
@apply text-sm font-medium truncate mb-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.song-preview-artist {
|
|
||||||
@apply text-xs text-gray-500 dark:text-gray-400 truncate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.n-dropdown-option-body--render) {
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="song-item"
|
||||||
|
@contextmenu.prevent="handleContextMenu"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
@dblclick.stop="playMusicEvent(item)"
|
||||||
|
>
|
||||||
|
<slot name="index"></slot>
|
||||||
|
<slot name="select" v-if="selectable"></slot>
|
||||||
|
<slot name="image"></slot>
|
||||||
|
<slot name="content"></slot>
|
||||||
|
<slot name="operating"></slot>
|
||||||
|
|
||||||
|
<song-item-dropdown
|
||||||
|
v-if="isElectron"
|
||||||
|
:item="item"
|
||||||
|
:show="showDropdown"
|
||||||
|
:x="dropdownX"
|
||||||
|
:y="dropdownY"
|
||||||
|
:is-favorite="isFavorite"
|
||||||
|
:is-dislike="isDislike"
|
||||||
|
:can-remove="canRemove"
|
||||||
|
@update:show="showDropdown = $event"
|
||||||
|
@play="playMusicEvent(item)"
|
||||||
|
@play-next="handlePlayNext"
|
||||||
|
@download="downloadMusic(item)"
|
||||||
|
@toggle-favorite="toggleFavorite"
|
||||||
|
@toggle-dislike="toggleDislike"
|
||||||
|
@remove="$emit('remove-song', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import SongItemDropdown from './SongItemDropdown.vue';
|
||||||
|
import { useSongItem } from '@/hooks/useSongItem';
|
||||||
|
import { isElectron } from '@/utils';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: SongResult;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
canRemove?: boolean;
|
||||||
|
isNext?: boolean;
|
||||||
|
index?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits(['play', 'select', 'remove-song']);
|
||||||
|
|
||||||
|
// 使用公共逻辑
|
||||||
|
const {
|
||||||
|
playLoading,
|
||||||
|
isPlaying,
|
||||||
|
isFavorite,
|
||||||
|
isDislike,
|
||||||
|
artists,
|
||||||
|
showDropdown,
|
||||||
|
dropdownX,
|
||||||
|
dropdownY,
|
||||||
|
isHovering,
|
||||||
|
handleImageLoad,
|
||||||
|
playMusicEvent,
|
||||||
|
toggleFavorite,
|
||||||
|
toggleDislike,
|
||||||
|
handlePlayNext,
|
||||||
|
handleContextMenu,
|
||||||
|
handleMenuClick,
|
||||||
|
handleArtistClick,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
downloadMusic
|
||||||
|
} = useSongItem(props);
|
||||||
|
|
||||||
|
// 处理图片加载
|
||||||
|
const imageLoad = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLImageElement;
|
||||||
|
if (!target) return;
|
||||||
|
await handleImageLoad(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换选择状态
|
||||||
|
const toggleSelect = () => {
|
||||||
|
emits('select', props.item.id, !props.selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 把图片处理、艺术家处理等公共方法暴露给子组件
|
||||||
|
defineExpose({
|
||||||
|
imageLoad,
|
||||||
|
toggleSelect,
|
||||||
|
handleArtistClick,
|
||||||
|
handleMenuClick,
|
||||||
|
playMusicEvent,
|
||||||
|
toggleFavorite,
|
||||||
|
handlePlayNext,
|
||||||
|
playLoading,
|
||||||
|
isPlaying,
|
||||||
|
isFavorite,
|
||||||
|
isDislike,
|
||||||
|
artists,
|
||||||
|
isHovering
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-ellipsis {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<base-song-item
|
||||||
|
:item="item"
|
||||||
|
:selectable="selectable"
|
||||||
|
:selected="selected"
|
||||||
|
:can-remove="canRemove"
|
||||||
|
:is-next="isNext"
|
||||||
|
:index="index"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
@remove-song="$emit('remove-song', $event)"
|
||||||
|
class="compact-song-item"
|
||||||
|
ref="baseItem"
|
||||||
|
>
|
||||||
|
<!-- 索引插槽 -->
|
||||||
|
<template #index>
|
||||||
|
<div v-if="index !== undefined" class="song-item-index" :class="{ 'text-green-500': isPlaying }">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 选择框插槽 -->
|
||||||
|
<template #select>
|
||||||
|
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
|
||||||
|
<n-checkbox :checked="selected" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 内容插槽 -->
|
||||||
|
<template #content>
|
||||||
|
<div class="song-item-content-compact">
|
||||||
|
<div class="song-item-content-compact-wrapper">
|
||||||
|
<div class="song-item-content-compact-title w-60 flex-shrink-0">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
|
||||||
|
{{ item.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-compact-artist">
|
||||||
|
<n-ellipsis line-clamp="1">
|
||||||
|
<template v-for="(artist, index) in artists" :key="index">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer hover:text-green-500"
|
||||||
|
@click.stop="onArtistClick(artist.id)"
|
||||||
|
>{{ artist.name }}</span
|
||||||
|
>
|
||||||
|
<span v-if="index < artists.length - 1"> / </span>
|
||||||
|
</template>
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-compact-album">
|
||||||
|
<n-ellipsis line-clamp="1">{{ item.al?.name || '-' }}</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-compact-duration">
|
||||||
|
{{ formatDuration(getDuration(item)) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 操作插槽 -->
|
||||||
|
<template #operating>
|
||||||
|
<div class="song-item-operating-compact">
|
||||||
|
<div v-if="favorite" class="song-item-operating-like" :class="{ 'opacity-0': !isHovering && !isFavorite }">
|
||||||
|
<i
|
||||||
|
class="iconfont icon-likefill"
|
||||||
|
:class="{ 'like-active': isFavorite }"
|
||||||
|
@click.stop="onToggleFavorite"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="song-item-operating-play animate__animated"
|
||||||
|
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading, 'opacity-0': !isHovering && !isPlaying }"
|
||||||
|
@click="onPlayMusic"
|
||||||
|
>
|
||||||
|
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
||||||
|
<i v-else class="iconfont icon-playfill"></i>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-operating-menu" @click.stop="onMenuClick" :class="{ 'opacity-0': !isHovering && !isPlaying }">
|
||||||
|
<i class="iconfont ri-more-fill"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</base-song-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { NCheckbox, NEllipsis } from 'naive-ui';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { usePlayerStore } from '@/store';
|
||||||
|
import BaseSongItem from './BaseSongItem.vue';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
item: SongResult;
|
||||||
|
favorite?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
canRemove?: boolean;
|
||||||
|
isNext?: boolean;
|
||||||
|
index?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
favorite: true,
|
||||||
|
selectable: false,
|
||||||
|
selected: false,
|
||||||
|
canRemove: false,
|
||||||
|
isNext: false,
|
||||||
|
index: undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineEmits(['play', 'select', 'remove-song']);
|
||||||
|
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
|
||||||
|
|
||||||
|
// 从基础组件获取响应式状态
|
||||||
|
const play = computed(() => playerStore.isPlay);
|
||||||
|
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
|
||||||
|
const playLoading = computed(() => baseItem.value?.playLoading || false);
|
||||||
|
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
|
||||||
|
const isHovering = computed(() => baseItem.value?.isHovering || false);
|
||||||
|
const artists = computed(() => baseItem.value?.artists || []);
|
||||||
|
|
||||||
|
// 包装方法,避免直接访问可能为undefined的ref
|
||||||
|
const onToggleSelect = () => baseItem.value?.toggleSelect();
|
||||||
|
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
|
||||||
|
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
|
||||||
|
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
|
||||||
|
const onMenuClick = (event: MouseEvent) => baseItem.value?.handleMenuClick(event);
|
||||||
|
|
||||||
|
// 从useSongItem.ts导入格式化时长和获取时长方法
|
||||||
|
const getDuration = (item: SongResult): number => {
|
||||||
|
if (item.duration) return item.duration;
|
||||||
|
if (typeof item.dt === 'number') return item.dt;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms: number): string => {
|
||||||
|
if (!ms) return '--:--';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.compact-song-item {
|
||||||
|
@apply rounded-lg p-2 h-12 mb-1 border-b dark:border-gray-800 border-gray-100;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-gray-50 dark:bg-gray-700;
|
||||||
|
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-index {
|
||||||
|
@apply w-8 text-center text-gray-500 dark:text-gray-400 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-select {
|
||||||
|
@apply mr-3 cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-content-compact {
|
||||||
|
@apply flex-1 flex items-center gap-4;
|
||||||
|
|
||||||
|
&-wrapper {
|
||||||
|
@apply flex-1 min-w-0 flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
@apply text-sm cursor-pointer text-gray-900 dark:text-white flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-artist {
|
||||||
|
@apply w-40 text-sm text-gray-500 dark:text-gray-400 ml-2 flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-album {
|
||||||
|
@apply w-32 flex items-center text-sm text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-duration {
|
||||||
|
@apply w-16 flex items-center text-sm text-gray-500 dark:text-gray-400 text-right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-compact {
|
||||||
|
@apply border-none bg-transparent gap-2 flex items-center;
|
||||||
|
|
||||||
|
.song-item-operating-like,
|
||||||
|
.song-item-operating-play,
|
||||||
|
.song-item-operating-menu {
|
||||||
|
@apply transition-opacity duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-play {
|
||||||
|
@apply w-7 h-7 flex items-center justify-center cursor-pointer rounded-full bg-gray-300 dark:bg-gray-800 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-like {
|
||||||
|
@apply mr-1 ml-0 cursor-pointer;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base transition text-gray-500 dark:text-gray-400 hover:text-red-500;
|
||||||
|
}
|
||||||
|
.like-active {
|
||||||
|
@apply text-red-500 dark:text-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating-menu {
|
||||||
|
@apply cursor-pointer flex items-center justify-center px-2;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-0 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局应用
|
||||||
|
:deep(.text-ellipsis) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<base-song-item
|
||||||
|
:item="item"
|
||||||
|
:selectable="selectable"
|
||||||
|
:selected="selected"
|
||||||
|
:can-remove="canRemove"
|
||||||
|
:is-next="isNext"
|
||||||
|
:index="index"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
@remove-song="$emit('remove-song', $event)"
|
||||||
|
class="list-song-item"
|
||||||
|
ref="baseItem"
|
||||||
|
>
|
||||||
|
<!-- 选择框插槽 -->
|
||||||
|
<template #select>
|
||||||
|
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
|
||||||
|
<n-checkbox :checked="selected" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 图片插槽 -->
|
||||||
|
<template #image>
|
||||||
|
<n-image
|
||||||
|
v-if="item.picUrl"
|
||||||
|
:src="getImgUrl(item.picUrl, '100y100')"
|
||||||
|
class="song-item-img"
|
||||||
|
preview-disabled
|
||||||
|
:img-props="{
|
||||||
|
crossorigin: 'anonymous'
|
||||||
|
}"
|
||||||
|
@load="onImageLoad"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 内容插槽 -->
|
||||||
|
<template #content>
|
||||||
|
<div class="song-item-content">
|
||||||
|
<div class="song-item-content-wrapper">
|
||||||
|
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
|
||||||
|
{{ item.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
<div class="song-item-content-divider">-</div>
|
||||||
|
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
|
||||||
|
<template v-for="(artist, index) in artists" :key="index">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer hover:text-green-500"
|
||||||
|
@click.stop="onArtistClick(artist.id)"
|
||||||
|
>{{ artist.name }}</span
|
||||||
|
>
|
||||||
|
<span v-if="index < artists.length - 1"> / </span>
|
||||||
|
</template>
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 操作插槽 -->
|
||||||
|
<template #operating>
|
||||||
|
<div class="song-item-operating song-item-operating-list">
|
||||||
|
<div v-if="favorite" class="song-item-operating-like">
|
||||||
|
<i
|
||||||
|
class="iconfont icon-likefill"
|
||||||
|
:class="{ 'like-active': isFavorite }"
|
||||||
|
@click.stop="onToggleFavorite"
|
||||||
|
></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="onPlayMusic"
|
||||||
|
>
|
||||||
|
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
||||||
|
<i v-else class="iconfont icon-playfill"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</base-song-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { NCheckbox, NEllipsis, NImage } from 'naive-ui';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { usePlayerStore } from '@/store';
|
||||||
|
import BaseSongItem from './BaseSongItem.vue';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
import { getImgUrl } from '@/utils';
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
item: SongResult;
|
||||||
|
favorite?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
canRemove?: boolean;
|
||||||
|
isNext?: boolean;
|
||||||
|
index?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
favorite: true,
|
||||||
|
selectable: false,
|
||||||
|
selected: false,
|
||||||
|
canRemove: false,
|
||||||
|
isNext: false,
|
||||||
|
index: undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineEmits(['play', 'select', 'remove-song']);
|
||||||
|
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
|
||||||
|
|
||||||
|
// 从基础组件获取响应式状态
|
||||||
|
const play = computed(() => playerStore.isPlay);
|
||||||
|
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
|
||||||
|
const playLoading = computed(() => baseItem.value?.playLoading || false);
|
||||||
|
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
|
||||||
|
const artists = computed(() => baseItem.value?.artists || []);
|
||||||
|
|
||||||
|
// 包装方法,避免直接访问可能为undefined的ref
|
||||||
|
const onToggleSelect = () => baseItem.value?.toggleSelect();
|
||||||
|
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
|
||||||
|
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
|
||||||
|
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
|
||||||
|
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.list-song-item {
|
||||||
|
@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-list {
|
||||||
|
@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,184 @@
|
|||||||
|
<template>
|
||||||
|
<base-song-item
|
||||||
|
:item="item"
|
||||||
|
:selectable="selectable"
|
||||||
|
:selected="selected"
|
||||||
|
:can-remove="canRemove"
|
||||||
|
:is-next="isNext"
|
||||||
|
:index="index"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
@remove-song="$emit('remove-song', $event)"
|
||||||
|
class="mini-song-item"
|
||||||
|
ref="baseItem"
|
||||||
|
>
|
||||||
|
<!-- 选择框插槽 -->
|
||||||
|
<template #select>
|
||||||
|
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
|
||||||
|
<n-checkbox :checked="selected" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 图片插槽 -->
|
||||||
|
<template #image>
|
||||||
|
<n-image
|
||||||
|
v-if="item.picUrl"
|
||||||
|
:src="getImgUrl(item.picUrl, '100y100')"
|
||||||
|
class="song-item-img"
|
||||||
|
preview-disabled
|
||||||
|
:img-props="{
|
||||||
|
crossorigin: 'anonymous'
|
||||||
|
}"
|
||||||
|
@load="onImageLoad"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 内容插槽 -->
|
||||||
|
<template #content>
|
||||||
|
<div class="song-item-content">
|
||||||
|
<div class="song-item-content-title">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">
|
||||||
|
{{ item.name }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-name">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||||
|
<template v-for="(artist, index) in artists" :key="index">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer hover:text-green-500"
|
||||||
|
@click.stop="onArtistClick(artist.id)"
|
||||||
|
>{{ artist.name }}</span
|
||||||
|
>
|
||||||
|
<span v-if="index < artists.length - 1"> / </span>
|
||||||
|
</template>
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 操作插槽 -->
|
||||||
|
<template #operating>
|
||||||
|
<div class="song-item-operating">
|
||||||
|
<div v-if="favorite" class="song-item-operating-like">
|
||||||
|
<i
|
||||||
|
class="iconfont icon-likefill"
|
||||||
|
:class="{ 'like-active': isFavorite }"
|
||||||
|
@click.stop="onToggleFavorite"
|
||||||
|
></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="onPlayMusic"
|
||||||
|
>
|
||||||
|
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
||||||
|
<i v-else class="iconfont icon-playfill"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</base-song-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { NCheckbox, NEllipsis, NImage } from 'naive-ui';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { usePlayerStore } from '@/store';
|
||||||
|
import BaseSongItem from './BaseSongItem.vue';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
import { getImgUrl } from '@/utils';
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
item: SongResult;
|
||||||
|
favorite?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
canRemove?: boolean;
|
||||||
|
isNext?: boolean;
|
||||||
|
index?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
favorite: true,
|
||||||
|
selectable: false,
|
||||||
|
selected: false,
|
||||||
|
canRemove: false,
|
||||||
|
isNext: false,
|
||||||
|
index: undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineEmits(['play', 'select', 'remove-song']);
|
||||||
|
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
|
||||||
|
|
||||||
|
// 从基础组件获取响应式状态
|
||||||
|
const play = computed(() => playerStore.isPlay);
|
||||||
|
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
|
||||||
|
const playLoading = computed(() => baseItem.value?.playLoading || false);
|
||||||
|
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
|
||||||
|
const artists = computed(() => baseItem.value?.artists || []);
|
||||||
|
|
||||||
|
// 包装方法,避免直接访问可能为undefined的ref
|
||||||
|
const onToggleSelect = () => baseItem.value?.toggleSelect();
|
||||||
|
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
|
||||||
|
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
|
||||||
|
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
|
||||||
|
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mini-song-item {
|
||||||
|
@apply p-2 rounded-2xl;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-light-100 dark:bg-dark-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-img {
|
||||||
|
@apply w-10 h-10 mr-2 rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-content {
|
||||||
|
@apply flex-1;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
@apply text-sm text-gray-900 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-name {
|
||||||
|
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-operating {
|
||||||
|
@apply flex items-center rounded-full ml-4 pl-2 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-like {
|
||||||
|
@apply mr-1 ml-1 cursor-pointer;
|
||||||
|
|
||||||
|
.icon-likefill {
|
||||||
|
@apply text-base transition text-gray-500 dark:text-gray-400 hover:text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-active {
|
||||||
|
@apply text-red-500 dark:text-red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-play {
|
||||||
|
@apply cursor-pointer rounded-full w-8 h-8 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<n-dropdown
|
||||||
|
v-if="isElectron"
|
||||||
|
:show="show"
|
||||||
|
:x="x"
|
||||||
|
:y="y"
|
||||||
|
:options="dropdownOptions"
|
||||||
|
:z-index="99999999"
|
||||||
|
placement="bottom-start"
|
||||||
|
@clickoutside="$emit('update:show', false)"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MenuOption } from 'naive-ui';
|
||||||
|
import { NEllipsis, NImage, NDropdown } from 'naive-ui';
|
||||||
|
import { computed, h, inject } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
import { getImgUrl, isElectron } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: SongResult;
|
||||||
|
show: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
isFavorite: boolean;
|
||||||
|
isDislike: boolean;
|
||||||
|
canRemove?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emits = defineEmits([
|
||||||
|
'update:show',
|
||||||
|
'select',
|
||||||
|
'play',
|
||||||
|
'play-next',
|
||||||
|
'download',
|
||||||
|
'add-to-playlist',
|
||||||
|
'toggle-favorite',
|
||||||
|
'toggle-dislike',
|
||||||
|
'remove'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
|
||||||
|
|
||||||
|
// 渲染歌曲预览
|
||||||
|
const renderSongPreview = () => {
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'flex items-center gap-3 px-2 py-1 dark:border-gray-800'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(NImage, {
|
||||||
|
src: getImgUrl(props.item.picUrl || props.item.al?.picUrl, '100y100'),
|
||||||
|
class: 'w-10 h-10 rounded-lg flex-shrink-0',
|
||||||
|
previewDisabled: true,
|
||||||
|
imgProps: {
|
||||||
|
crossorigin: 'anonymous'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'flex-1 min-w-0 py-1 overflow-hidden'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'mb-1 overflow-hidden'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
NEllipsis,
|
||||||
|
{
|
||||||
|
lineClamp: 1,
|
||||||
|
depth: 1,
|
||||||
|
class: 'text-sm font-medium w-full',
|
||||||
|
style: 'max-width: 150px; min-width: 120px;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => props.item.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
class: 'text-xs text-gray-500 dark:text-gray-400 overflow-hidden'
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
NEllipsis,
|
||||||
|
{
|
||||||
|
lineClamp: 1,
|
||||||
|
style: 'max-width: 150px;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => {
|
||||||
|
const artistNames = (props.item.ar || props.item.song?.artists)?.map((a) => a.name).join(' / ');
|
||||||
|
return artistNames || '未知艺术家';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 下拉菜单选项
|
||||||
|
const dropdownOptions = computed<MenuOption[]>(() => {
|
||||||
|
const options: MenuOption[] = [
|
||||||
|
{
|
||||||
|
key: 'header',
|
||||||
|
type: 'render',
|
||||||
|
render: renderSongPreview
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'divider1',
|
||||||
|
type: 'divider'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('songItem.menu.play'),
|
||||||
|
key: 'play',
|
||||||
|
icon: () => h('i', { class: 'iconfont ri-play-circle-line' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('songItem.menu.playNext'),
|
||||||
|
key: 'playNext',
|
||||||
|
icon: () => h('i', { class: 'iconfont ri-play-list-2-line' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
key: 'd1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('songItem.menu.download'),
|
||||||
|
key: 'download',
|
||||||
|
icon: () => h('i', { class: 'iconfont ri-download-line' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('songItem.menu.addToPlaylist'),
|
||||||
|
key: 'addToPlaylist',
|
||||||
|
icon: () => h('i', { class: 'iconfont ri-folder-add-line' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: props.isFavorite ? t('songItem.menu.unfavorite') : t('songItem.menu.favorite'),
|
||||||
|
key: 'favorite',
|
||||||
|
icon: () =>
|
||||||
|
h('i', {
|
||||||
|
class: `iconfont ${props.isFavorite ? 'ri-heart-fill text-red-500' : 'ri-heart-line'}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: props.isDislike ? t('songItem.menu.undislike') : t('songItem.menu.dislike'),
|
||||||
|
key: 'dislike',
|
||||||
|
icon: () => h('i', { class: `iconfont ${props.isDislike ? 'ri-dislike-fill text-green-500': 'ri-dislike-line'}` })
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (props.canRemove) {
|
||||||
|
options.push(
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
key: 'd2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('songItem.menu.removeFromPlaylist'),
|
||||||
|
key: 'remove',
|
||||||
|
icon: () => h('i', { class: 'iconfont ri-delete-bin-line' })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理选择
|
||||||
|
const handleSelect = (key: string | number) => {
|
||||||
|
emits('update:show', false);
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'download':
|
||||||
|
emits('download');
|
||||||
|
break;
|
||||||
|
case 'playNext':
|
||||||
|
emits('play-next');
|
||||||
|
break;
|
||||||
|
case 'addToPlaylist':
|
||||||
|
openPlaylistDrawer?.(props.item.id);
|
||||||
|
break;
|
||||||
|
case 'favorite':
|
||||||
|
emits('toggle-favorite');
|
||||||
|
break;
|
||||||
|
case 'play':
|
||||||
|
emits('play');
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
emits('remove', props.item.id);
|
||||||
|
break;
|
||||||
|
case 'dislike':
|
||||||
|
emits('toggle-dislike');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
:deep(.n-dropdown-menu) {
|
||||||
|
@apply min-w-[240px] overflow-hidden rounded-lg border dark:border-gray-800;
|
||||||
|
|
||||||
|
.n-dropdown-option {
|
||||||
|
@apply h-9 text-sm;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-dropdown-option-body {
|
||||||
|
@apply h-full;
|
||||||
|
|
||||||
|
.n-dropdown-option-body__prefix {
|
||||||
|
@apply w-8 flex justify-center items-center;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.n-dropdown-divider {
|
||||||
|
@apply my-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-dropdown-option-body--render) {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<base-song-item
|
||||||
|
:item="item"
|
||||||
|
:selectable="selectable"
|
||||||
|
:selected="selected"
|
||||||
|
:can-remove="canRemove"
|
||||||
|
:is-next="isNext"
|
||||||
|
:index="index"
|
||||||
|
@play="$emit('play', $event)"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
@remove-song="$emit('remove-song', $event)"
|
||||||
|
class="standard-song-item"
|
||||||
|
ref="baseItem"
|
||||||
|
>
|
||||||
|
<!-- 选择框插槽 -->
|
||||||
|
<template #select>
|
||||||
|
<div v-if="baseItem && selectable" class="song-item-select" @click.stop="onToggleSelect">
|
||||||
|
<n-checkbox :checked="selected" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 图片插槽 -->
|
||||||
|
<template #image>
|
||||||
|
<n-image
|
||||||
|
v-if="item.picUrl"
|
||||||
|
:src="getImgUrl(item.picUrl, '100y100')"
|
||||||
|
class="song-item-img"
|
||||||
|
preview-disabled
|
||||||
|
:img-props="{
|
||||||
|
crossorigin: 'anonymous'
|
||||||
|
}"
|
||||||
|
@load="onImageLoad"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 内容插槽 -->
|
||||||
|
<template #content>
|
||||||
|
<div class="song-item-content">
|
||||||
|
<div class="song-item-content-title">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1" :class="{ 'text-green-500': isPlaying }">{{ item.name }}</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
<div class="song-item-content-name">
|
||||||
|
<n-ellipsis class="text-ellipsis" line-clamp="1">
|
||||||
|
<template v-for="(artist, index) in artists" :key="index">
|
||||||
|
<span
|
||||||
|
class="cursor-pointer hover:text-green-500"
|
||||||
|
@click.stop="onArtistClick(artist.id)"
|
||||||
|
>{{ artist.name }}</span
|
||||||
|
>
|
||||||
|
<span v-if="index < artists.length - 1"> / </span>
|
||||||
|
</template>
|
||||||
|
</n-ellipsis>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 操作插槽 -->
|
||||||
|
<template #operating>
|
||||||
|
<div class="song-item-operating">
|
||||||
|
<div v-if="favorite" class="song-item-operating-like">
|
||||||
|
<i
|
||||||
|
class="iconfont icon-likefill"
|
||||||
|
:class="{ 'like-active': isFavorite }"
|
||||||
|
@click.stop="onToggleFavorite"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<n-tooltip v-if="isNext" trigger="hover" :z-index="9999999" :delay="400">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="song-item-operating-next" @click.stop="onPlayNext">
|
||||||
|
<i class="iconfont ri-skip-forward-fill"></i>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ t('songItem.menu.playNext') }}
|
||||||
|
</n-tooltip>
|
||||||
|
<div
|
||||||
|
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
|
||||||
|
:class="{ 'bg-green-600': isPlaying, 'animate__flipInY': playLoading }"
|
||||||
|
@click="onPlayMusic"
|
||||||
|
>
|
||||||
|
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
|
||||||
|
<i v-else class="iconfont icon-playfill"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</base-song-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { NCheckbox, NEllipsis, NImage, NTooltip } from 'naive-ui';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import { usePlayerStore } from '@/store';
|
||||||
|
import BaseSongItem from './BaseSongItem.vue';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
import { getImgUrl } from '@/utils';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
item: SongResult;
|
||||||
|
favorite?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
canRemove?: boolean;
|
||||||
|
isNext?: boolean;
|
||||||
|
index?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
favorite: true,
|
||||||
|
selectable: false,
|
||||||
|
selected: false,
|
||||||
|
canRemove: false,
|
||||||
|
isNext: false,
|
||||||
|
index: undefined
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineEmits(['play', 'select', 'remove-song']);
|
||||||
|
const baseItem = ref<InstanceType<typeof BaseSongItem>>();
|
||||||
|
|
||||||
|
// 从playerStore和baseItem获取响应式状态
|
||||||
|
const play = computed(() => playerStore.isPlay);
|
||||||
|
const isPlaying = computed(() => baseItem.value?.isPlaying || false);
|
||||||
|
const playLoading = computed(() => baseItem.value?.playLoading || false);
|
||||||
|
const isFavorite = computed(() => baseItem.value?.isFavorite || false);
|
||||||
|
const artists = computed(() => baseItem.value?.artists || []);
|
||||||
|
|
||||||
|
// 包装方法,避免直接访问可能为undefined的ref
|
||||||
|
const onToggleSelect = () => baseItem.value?.toggleSelect();
|
||||||
|
const onImageLoad = (event: Event) => baseItem.value?.imageLoad(event);
|
||||||
|
const onArtistClick = (id: number) => baseItem.value?.handleArtistClick(id);
|
||||||
|
const onToggleFavorite = (event: Event) => baseItem.value?.toggleFavorite(event);
|
||||||
|
const onPlayMusic = () => baseItem.value?.playMusicEvent(props.item);
|
||||||
|
const onPlayNext = () => baseItem.value?.handlePlayNext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.standard-song-item {
|
||||||
|
&:hover {
|
||||||
|
@apply bg-light-100 dark:bg-dark-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-img {
|
||||||
|
@apply w-12 h-12 rounded-2xl mr-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item-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 transition-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-next {
|
||||||
|
@apply mr-2 cursor-pointer transition-all;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-active {
|
||||||
|
@apply text-red-500 dark: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-item-select {
|
||||||
|
@apply mr-3 cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { usePlayerStore } from '@/store';
|
||||||
|
import type { SongResult } from '@/type/music';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { getImgUrl } from '@/utils';
|
||||||
|
import { getImageBackground } from '@/utils/linearColor';
|
||||||
|
import { useMessage, useDialog } from 'naive-ui';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useDownload } from './useDownload';
|
||||||
|
import { useArtist } from './useArtist';
|
||||||
|
|
||||||
|
export function useSongItem(props: {
|
||||||
|
item: SongResult;
|
||||||
|
canRemove?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
const { downloadMusic } = useDownload();
|
||||||
|
const { navigateToArtist } = useArtist();
|
||||||
|
|
||||||
|
// 状态变量
|
||||||
|
const showDropdown = ref(false);
|
||||||
|
const dropdownX = ref(0);
|
||||||
|
const dropdownY = ref(0);
|
||||||
|
const isHovering = ref(false);
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const play = computed(() => playerStore.isPlay);
|
||||||
|
const playMusic = computed(() => playerStore.playMusic);
|
||||||
|
const playLoading = computed(
|
||||||
|
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
|
||||||
|
);
|
||||||
|
const isPlaying = computed(() => playMusic.value.id === props.item.id);
|
||||||
|
|
||||||
|
// 收藏与不喜欢状态
|
||||||
|
const isFavorite = computed(() => {
|
||||||
|
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
|
||||||
|
return playerStore.favoriteList.includes(numericId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDislike = computed(() => {
|
||||||
|
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
|
||||||
|
return playerStore.dislikeList.includes(numericId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取艺术家列表
|
||||||
|
const artists = computed(() => {
|
||||||
|
return (props.item.ar || props.item.song?.artists)?.slice(0, 4) || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理图片加载
|
||||||
|
const handleImageLoad = async (imageElement: HTMLImageElement) => {
|
||||||
|
if (!imageElement) return;
|
||||||
|
|
||||||
|
const { backgroundColor } = await getImageBackground(imageElement);
|
||||||
|
// eslint-disable-next-line vue/no-mutating-props
|
||||||
|
props.item.backgroundColor = backgroundColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 播放音乐
|
||||||
|
const playMusicEvent = async (item: SongResult) => {
|
||||||
|
try {
|
||||||
|
const result = await playerStore.setPlay(item);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('播放失败');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放出错:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换收藏状态
|
||||||
|
const toggleFavorite = async (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
|
||||||
|
|
||||||
|
if (isFavorite.value) {
|
||||||
|
playerStore.removeFromFavorite(numericId);
|
||||||
|
} else {
|
||||||
|
playerStore.addToFavorite(numericId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换不喜欢状态
|
||||||
|
const toggleDislike = async (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isDislike.value) {
|
||||||
|
playerStore.removeFromDislikeList(props.item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.warning({
|
||||||
|
title: t('songItem.dialog.dislike.title'),
|
||||||
|
content: t('songItem.dialog.dislike.content'),
|
||||||
|
positiveText: t('songItem.dialog.dislike.positiveText'),
|
||||||
|
negativeText: t('songItem.dialog.dislike.negativeText'),
|
||||||
|
onPositiveClick: () => {
|
||||||
|
playerStore.addToDislikeList(props.item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到下一首播放
|
||||||
|
const handlePlayNext = () => {
|
||||||
|
playerStore.addToNextPlay(props.item);
|
||||||
|
message.success(t('songItem.message.addedToNextPlay'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取歌曲时长
|
||||||
|
const getDuration = (item: SongResult): number => {
|
||||||
|
if (item.duration) return item.duration;
|
||||||
|
if (typeof item.dt === 'number') return item.dt;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时长
|
||||||
|
const formatDuration = (ms: number): string => {
|
||||||
|
if (!ms) return '--:--';
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理右键菜单
|
||||||
|
const handleContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showDropdown.value = true;
|
||||||
|
dropdownX.value = e.clientX;
|
||||||
|
dropdownY.value = e.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理菜单点击
|
||||||
|
const handleMenuClick = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showDropdown.value = true;
|
||||||
|
dropdownX.value = e.clientX;
|
||||||
|
dropdownY.value = e.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理艺术家点击
|
||||||
|
const handleArtistClick = (id: number) => {
|
||||||
|
navigateToArtist(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 鼠标悬停处理
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
isHovering.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
isHovering.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
play,
|
||||||
|
playMusic,
|
||||||
|
playLoading,
|
||||||
|
isPlaying,
|
||||||
|
isFavorite,
|
||||||
|
isDislike,
|
||||||
|
artists,
|
||||||
|
showDropdown,
|
||||||
|
dropdownX,
|
||||||
|
dropdownY,
|
||||||
|
isHovering,
|
||||||
|
playerStore,
|
||||||
|
message,
|
||||||
|
getImgUrl,
|
||||||
|
handleImageLoad,
|
||||||
|
playMusicEvent,
|
||||||
|
toggleFavorite,
|
||||||
|
toggleDislike,
|
||||||
|
handlePlayNext,
|
||||||
|
getDuration,
|
||||||
|
formatDuration,
|
||||||
|
handleContextMenu,
|
||||||
|
handleMenuClick,
|
||||||
|
handleArtistClick,
|
||||||
|
handleMouseEnter,
|
||||||
|
handleMouseLeave,
|
||||||
|
downloadMusic
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
:style="isMobile && playerStore.musicFull ? 'bottom: 0;' : ''"
|
:style="isMobile && playerStore.musicFull ? 'bottom: 0;' : ''"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
<install-app-modal v-if="!isElectron"></install-app-modal>
|
||||||
|
<update-modal v-if="isElectron" />
|
||||||
|
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
|
||||||
|
<SleepTimerTop v-if="!isMobile"/>
|
||||||
<!-- 下载管理抽屉 -->
|
<!-- 下载管理抽屉 -->
|
||||||
<download-drawer
|
<download-drawer
|
||||||
v-if="
|
v-if="
|
||||||
@@ -47,12 +52,7 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<!-- 播放列表抽屉 -->
|
<!-- 播放列表抽屉 -->
|
||||||
<play-list-drawer />
|
<playing-list-drawer />
|
||||||
</div>
|
|
||||||
<install-app-modal v-if="!isElectron"></install-app-modal>
|
|
||||||
<update-modal v-if="isElectron" />
|
|
||||||
<playlist-drawer v-model="showPlaylistDrawer" :song-id="currentSongId" />
|
|
||||||
<SleepTimerTop v-if="!isMobile"/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ const PlayBar = defineAsyncComponent(() => import('@/components/player/PlayBar.v
|
|||||||
const MobilePlayBar = defineAsyncComponent(() => import('@/components/player/MobilePlayBar.vue'));
|
const MobilePlayBar = defineAsyncComponent(() => import('@/components/player/MobilePlayBar.vue'));
|
||||||
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
|
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
|
||||||
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
|
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
|
||||||
const PlayListDrawer = defineAsyncComponent(() => import('@/components/player/PlayListDrawer.vue'));
|
const PlayingListDrawer = defineAsyncComponent(() => import('@/components/player/PlayingListDrawer.vue'));
|
||||||
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
|
const PlaylistDrawer = defineAsyncComponent(() => import('@/components/common/PlaylistDrawer.vue'));
|
||||||
|
|
||||||
const playerStore = usePlayerStore();
|
const playerStore = usePlayerStore();
|
||||||
@@ -112,9 +112,9 @@ const showPlaylistDrawer = ref(false);
|
|||||||
const currentSongId = ref<number | undefined>();
|
const currentSongId = ref<number | undefined>();
|
||||||
|
|
||||||
// 提供一个方法来打开歌单抽屉
|
// 提供一个方法来打开歌单抽屉
|
||||||
const openPlaylistDrawer = (songId: number) => {
|
const openPlaylistDrawer = (songId: number, isOpen: boolean = true) => {
|
||||||
currentSongId.value = songId;
|
currentSongId.value = songId;
|
||||||
showPlaylistDrawer.value = true;
|
showPlaylistDrawer.value = isOpen;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将方法提供给全局
|
// 将方法提供给全局
|
||||||
|
|||||||
Reference in New Issue
Block a user