mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-08-06 03:44:18 +08:00
- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画) - 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索 - 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸) - 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择) - 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar) - 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表 - audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store - 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
276 lines
11 KiB
Vue
276 lines
11 KiB
Vue
<template>
|
||
<Transition name="drawer">
|
||
<div v-if="visible" class="fixed inset-0 z-50 flex flex-col">
|
||
<!-- 背景层:fade in 覆盖全屏 -->
|
||
<div
|
||
class="absolute inset-0 backdrop-blur-xl"
|
||
:class="!ui.dominantColor && 'bg-surface/95'"
|
||
:style="ui.dominantColor ? { backgroundColor: ui.dominantColor } : {}"
|
||
></div>
|
||
<div v-if="ui.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||
|
||
<!-- 内容层:slide up/down -->
|
||
<div class="relative z-10 flex flex-col flex-1 min-h-0 drawer-content">
|
||
<TitleBar :dark-mode="!!ui.dominantColor" transparent @close="ui.closeRoamDrawer()">
|
||
<template #left>
|
||
<button @click="ui.closeRoamDrawer()" :class="dc ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
||
<IconChevronDown class="w-5 h-5" />
|
||
</button>
|
||
</template>
|
||
</TitleBar>
|
||
|
||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
|
||
<!-- 左侧:封面 + 歌曲信息 -->
|
||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||
<img
|
||
v-if="roamCoverUrl && !roamCoverError"
|
||
:src="roamCoverUrl"
|
||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||
@error="roamCoverError = true"
|
||
/>
|
||
<div
|
||
v-else
|
||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||
:class="dc ? 'bg-white/10' : 'bg-muted'"
|
||
>
|
||
<IconMusic class="w-16 h-16" :class="dc ? 'text-white/30' : 'text-content-4'" />
|
||
</div>
|
||
<h1 class="text-2xl font-bold text-center" :class="dc ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
||
<p class="mt-2 text-center" :class="dc ? 'text-white/70' : 'text-content-2'">
|
||
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
||
<span v-if="i > 0" :class="dc ? 'text-white/40' : 'text-content-3'">/</span>
|
||
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||
</template>
|
||
<template v-if="roamSong?.al?.name">
|
||
<span :class="dc ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
||
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
|
||
</template>
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 右侧:歌词/评论 -->
|
||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||
<div class="flex items-center gap-1 mb-3 px-4">
|
||
<button @click="ui.roamTab = 'lyric'"
|
||
class="px-3 py-1 rounded-full text-sm transition"
|
||
:class="tabClass(ui.roamTab === 'lyric')">
|
||
歌词
|
||
</button>
|
||
<button @click="ui.roamTab = 'comment'"
|
||
class="px-3 py-1 rounded-full text-sm transition"
|
||
:class="tabClass(ui.roamTab === 'comment')">
|
||
评论
|
||
</button>
|
||
<button v-if="hasTranslation" @click="toggleTranslation"
|
||
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
||
:class="tabClass(showTranslation)">
|
||
<IconLanguages class="w-3 h-3" />
|
||
译
|
||
</button>
|
||
</div>
|
||
<div v-show="ui.roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||
<p
|
||
v-for="(line, idx) in lyrics"
|
||
:key="idx"
|
||
:class="getRoamLyricClass(idx)"
|
||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-[font-size,opacity] duration-300 ease-out"
|
||
@click="seekToRoamLyric(line.time)"
|
||
@mouseenter="roamLyricHovering = true"
|
||
@mouseleave="roamLyricHovering = false"
|
||
>
|
||
{{ line.text }}
|
||
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
|
||
</p>
|
||
</div>
|
||
<div v-else :class="dc ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||
</div>
|
||
<div v-show="ui.roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||
<CommentSection v-if="roamSong" :type="0" :id="ui.commentSongId || roamSong.id" :key="ui.commentSongId || roamSong.id" :dark-mode="!!ui.dominantColor" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { usePlayerStore } from '../stores/player';
|
||
import { useUiStore } from '../stores/ui';
|
||
import { getCoverUrl, extractDominantColor } from '../utils/song';
|
||
import { useLyric } from '../composables/UserLyric';
|
||
import TitleBar from './TitleBar.vue';
|
||
import CommentSection from './CommentSection.vue';
|
||
import IconChevronDown from '~icons/lucide/chevron-down';
|
||
import IconMusic from '~icons/lucide/music';
|
||
import IconLanguages from '~icons/lucide/languages';
|
||
|
||
defineProps<{
|
||
visible: boolean;
|
||
}>();
|
||
|
||
const router = useRouter();
|
||
const player = usePlayerStore();
|
||
const ui = useUiStore();
|
||
|
||
// dominantColor 是否存在(模板中频繁使用)
|
||
const dc = computed(() => !!ui.dominantColor);
|
||
|
||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||
const roamLyricHovering = ref(false);
|
||
const roamLyricPadPx = ref(0);
|
||
const roamSong = computed(() => player.currentSong);
|
||
const roamCoverError = ref(false);
|
||
const roamCoverUrl = computed(() => {
|
||
if (!roamSong.value) return '';
|
||
return getCoverUrl(roamSong.value) || '';
|
||
});
|
||
|
||
watch(roamCoverUrl, async (url) => {
|
||
roamCoverError.value = false;
|
||
if (url) {
|
||
const color = await extractDominantColor(url);
|
||
ui.dominantColor = color;
|
||
} else {
|
||
ui.dominantColor = '';
|
||
}
|
||
});
|
||
|
||
let roamResizeObserver: ResizeObserver | null = null;
|
||
|
||
function updateRoamLyricPad() {
|
||
if (lyricScrollContainer.value) {
|
||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||
}
|
||
}
|
||
|
||
watch(() => ui.showRoamDrawer, (val) => {
|
||
if (val) {
|
||
nextTick(() => {
|
||
updateRoamLyricPad();
|
||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||
if (lyricScrollContainer.value) {
|
||
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
||
roamResizeObserver.observe(lyricScrollContainer.value);
|
||
}
|
||
scrollToRoamActiveLyric();
|
||
});
|
||
} else {
|
||
if (roamResizeObserver) {
|
||
roamResizeObserver.disconnect();
|
||
roamResizeObserver = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (roamResizeObserver) {
|
||
roamResizeObserver.disconnect();
|
||
roamResizeObserver = null;
|
||
}
|
||
});
|
||
|
||
watch(currentLyricIdx, () => {
|
||
if (ui.showRoamDrawer && !roamLyricHovering.value) {
|
||
nextTick(() => scrollToRoamActiveLyric());
|
||
}
|
||
});
|
||
|
||
watch(showTranslation, () => {
|
||
if (ui.showRoamDrawer && !roamLyricHovering.value) {
|
||
nextTick(() => scrollToRoamActiveLyric());
|
||
}
|
||
});
|
||
|
||
function scrollToRoamActiveLyric() {
|
||
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||
const container = lyricScrollContainer.value;
|
||
const active = container.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||
if (!active) return;
|
||
const target = active.offsetTop - container.clientHeight / 2 + active.clientHeight / 2;
|
||
const start = container.scrollTop;
|
||
const distance = target - start;
|
||
if (Math.abs(distance) < 1) return;
|
||
const duration = 400;
|
||
const startTime = performance.now();
|
||
function animate(now: number) {
|
||
const elapsed = now - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
const ease = 1 - Math.pow(1 - progress, 3);
|
||
container.scrollTop = start + distance * ease;
|
||
if (progress < 1) requestAnimationFrame(animate);
|
||
}
|
||
requestAnimationFrame(animate);
|
||
}
|
||
|
||
// Tab 按钮统一样式
|
||
function tabClass(active: boolean): string {
|
||
if (dc.value) {
|
||
return active ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80';
|
||
}
|
||
return active ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content';
|
||
}
|
||
|
||
function getTranslationClass(idx: number): string {
|
||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||
if (idx === currentLyricIdx.value) return dc.value ? 'text-[var(--c-accent)]' : 'text-accent-text';
|
||
if (diff === 1) return dc.value ? 'text-white/70' : 'text-content/70';
|
||
if (diff === 2) return dc.value ? 'text-white/50' : 'text-content-2/50';
|
||
return dc.value ? 'text-white/35' : 'text-content-3/35';
|
||
}
|
||
|
||
function getRoamLyricClass(idx: number): string {
|
||
const diff = Math.abs(idx - currentLyricIdx.value);
|
||
if (idx === currentLyricIdx.value) {
|
||
return dc.value
|
||
? 'roam-lyric-active font-bold text-xl text-[var(--c-accent)]'
|
||
: 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||
}
|
||
if (diff === 1) return dc.value ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
||
if (diff === 2) return dc.value ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
||
return dc.value ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
||
}
|
||
|
||
function seekToRoamLyric(time: number) {
|
||
if (time != null && player.duration > 0) {
|
||
player.seek(time);
|
||
}
|
||
}
|
||
|
||
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||
ui.closeRoamDrawer();
|
||
router.push(routeLocation);
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 外层容器:fade in/out */
|
||
.drawer-enter-active,
|
||
.drawer-leave-active { transition: opacity 0.3s ease; }
|
||
.drawer-enter-from,
|
||
.drawer-leave-to { opacity: 0; }
|
||
|
||
/* 内容层:slide up/down */
|
||
.drawer-enter-active .drawer-content,
|
||
.drawer-leave-active .drawer-content { transition: transform 0.3s ease; }
|
||
.drawer-enter-from .drawer-content,
|
||
.drawer-leave-to .drawer-content { transform: translateY(100%); }
|
||
|
||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||
|
||
/* 歌词行:长歌词允许换行(不再被截断);
|
||
锁定行高让字号在 active/diff 切换时盒模型高度不变,避免整列抖动乱跳。
|
||
行高取 active 字号(text-xl=1.25rem≈20px)对应舒适值 1.5×≈30px,
|
||
其余小字号在同一行高内垂直居中显示。 */
|
||
.roam-lyric-line {
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
</style>
|