mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
新功能: - 亮色主题:新增浅色外观模式,7种主题色各有对应亮色变体 - 封面主色背景:漫游抽屉自动提取封面主色,PlayerBar跟随继承 - 发现页重做:多类型搜索(歌曲/歌手/专辑)+搜索建议+搜索历史 - 漫游页重做:进入即播放,布局改为封面+歌名+播放/下一首/减少推荐 - 减少推荐:FM模式下可标记不推荐歌曲或歌手 - 列表风格统一:播放指示器跳动动画+hover播放图标+图标统一使用Lucide 修复: - 专辑页艺术家过多时窗口缩小竖排,改为自动换行 - FM播放时退出登录后首页仍可点击下一首 - 本地音乐播放时缓冲进度条未重置 - 亮色主题下多处文字不可见 - 退出FM模式时状态未正确清理 - 暗色模式下关闭抽屉时PlayerBar闪烁亮色(改用opacity过渡) - player.ts tickInterval双变量状态不同步,统一为clearTick/setTick 变更: - 移除播放列表按钮数字角标 - 主页卡片标题固定白色不随主题变化 - 全项目空catch块格式统一 - 清理冗余注释和代码
116 lines
5.3 KiB
Vue
116 lines
5.3 KiB
Vue
<template>
|
|
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
|
|
<slot name="index" :index="index" :is-current="isCurrent">
|
|
<div v-if="showIndex" class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
|
|
<div v-if="isCurrent && showPlayingOverlay" class="flex items-center justify-end">
|
|
<div class="flex items-center gap-[3px] h-4">
|
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
|
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
|
|
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
|
|
</div>
|
|
</div>
|
|
<template v-else>
|
|
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
|
|
<IconPlay class="hidden group-hover:block text-content" style="font-size: 14px" />
|
|
</template>
|
|
</div>
|
|
</slot>
|
|
|
|
<div :class="['rounded-md overflow-hidden flex-shrink-0 relative', coverClass]">
|
|
<img v-if="coverSrc" :src="coverSrc" class="w-full h-full object-cover" loading="lazy" />
|
|
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
|
|
<IconMusic style="font-size: 14px" class="text-content-4" />
|
|
</div>
|
|
<div v-if="isCurrent && showPlayingOverlay"
|
|
class="absolute inset-0 bg-black/30 flex items-center justify-center">
|
|
<div class="flex items-end gap-[2px] h-3">
|
|
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
|
|
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
|
|
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium truncate" :class="nameClass">{{ song.name }}</p>
|
|
<p class="text-xs text-content-2 truncate">
|
|
<template v-if="song.ar?.length">
|
|
<template v-for="(a, i) in song.ar" :key="a.id || i">
|
|
<span v-if="i > 0" class="text-content-3">/</span>
|
|
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
|
</template>
|
|
</template>
|
|
<template v-if="song.al?.name">
|
|
<span class="text-content-3 mx-1">·</span>
|
|
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
|
|
</template>
|
|
</p>
|
|
</div>
|
|
|
|
<slot name="actions">
|
|
<button v-if="showLike" @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
|
|
<IconHeart v-if="player.isLiked(song.id)" class="w-4 h-4 text-danger [&>path]:fill-current [&>path]:stroke-0" />
|
|
<IconHeart v-else class="w-4 h-4" />
|
|
</button>
|
|
<button v-if="showDownload" @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
|
|
<IconLoader2 v-if="download.isDownloading(song.id)" class="w-4 h-4 animate-spin" />
|
|
<IconCheck v-else-if="download.isDownloaded(song.id)" class="w-4 h-4 text-accent-text" />
|
|
<IconDownload v-else class="w-4 h-4" />
|
|
</button>
|
|
<SongItemMenu v-if="showMenu" :song-id="song.id" />
|
|
</slot>
|
|
|
|
<span v-if="showDuration && song.dt" class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.dt) }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { usePlayerStore } from '../stores/player';
|
|
import { useDownload } from '../composables/useDownload';
|
|
import { getCoverUrl, type Song } from '../utils/song';
|
|
import { formatDuration } from '../utils/format';
|
|
import SongItemMenu from './SongItemMenu.vue';
|
|
import IconPlay from '~icons/lucide/play';
|
|
import IconMusic from '~icons/lucide/music';
|
|
import IconHeart from '~icons/lucide/heart';
|
|
import IconLoader2 from '~icons/lucide/loader-2';
|
|
import IconCheck from '~icons/lucide/check';
|
|
import IconDownload from '~icons/lucide/download';
|
|
|
|
const router = useRouter();
|
|
const player = usePlayerStore();
|
|
const download = useDownload();
|
|
|
|
const props = withDefaults(defineProps<{
|
|
song: Song;
|
|
index: number;
|
|
isCurrent?: boolean;
|
|
showIndex?: boolean;
|
|
showLike?: boolean;
|
|
showDownload?: boolean;
|
|
showMenu?: boolean;
|
|
showDuration?: boolean;
|
|
showPlayingOverlay?: boolean;
|
|
coverSize?: string;
|
|
coverSizeParam?: string;
|
|
containerClass?: string;
|
|
}>(), {
|
|
isCurrent: false,
|
|
showIndex: false,
|
|
showLike: false,
|
|
showDownload: false,
|
|
showMenu: false,
|
|
showDuration: false,
|
|
showPlayingOverlay: false,
|
|
coverSize: 'w-10 h-10',
|
|
coverSizeParam: '',
|
|
containerClass: 'hover:bg-subtle',
|
|
});
|
|
|
|
const coverClass = computed(() => props.coverSize);
|
|
const coverSrc = computed(() => getCoverUrl(props.song, props.coverSizeParam));
|
|
const nameClass = computed(() => props.isCurrent ? 'text-accent-text' : '');
|
|
</script>
|