Files
Nekosonic-Music/src/App.vue
T
atdunbg 263e9b31d9 feat: v0.8.0 详情页架构重构、皮肤系统、侧边栏交互升级、底层模块拆分
- 详情页统一 DetailLayout 组件(常驻头部 + 独立滚动 + 紧凑态动画)
- 歌曲/评论 tab 切换(懒加载 + 保活),专辑模糊搜索
- 皮肤系统 skins.ts(19 语义色 + 14 预设 + 自定义皮肤 + 壁纸)
- 设置页皮肤模块(外观/主题色/皮肤编辑器/壁纸选择)
- 可折叠侧边栏 + TopBar + 折叠态歌单浮层(高度不越 PlayerBar)
- 首页扩展至 6 区块 + 通用卡片组件 + 虚拟歌曲列表
- audio.rs 拆为 8 模块,api.ts 拆为 api/ 目录,player store 拆为 4 store
- 修复播放竞态、seek 暂停、评论提前加载、紧凑态卡死等
2026-06-26 21:05:51 +08:00

409 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 壁纸层fixed 全屏最底层 -->
<div
v-if="settings.currentWallpaper.path"
class="fixed inset-0 z-0 pointer-events-none overflow-hidden"
>
<div
class="absolute inset-[-20px] bg-cover bg-center bg-no-repeat"
:style="wallpaperStyle"
></div>
</div>
<!-- 主题色遮罩层半透明主题色覆盖壁纸保证文字可读 -->
<div
v-if="settings.currentWallpaper.path"
class="fixed inset-0 z-[1] pointer-events-none"
:style="overlayStyle"
></div>
<!-- 主容器 -->
<div class="flex flex-col h-screen text-content overflow-hidden relative z-[2]" :style="rootBgStyle">
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
<Sidebar :mode="ui.sidebarMode" />
<div class="flex flex-col flex-1 overflow-hidden">
<TopBar @close="closeWindow" />
<main class="flex-1 overflow-hidden flex flex-col" :style="{ paddingBottom: player.currentSong ? '70px' : '0px' }">
<div ref="mainScrollRef" class="flex-1 overflow-y-auto min-h-0 relative">
<router-view v-slot="{ Component }">
<keep-alive :max="10" :include="keepAliveInclude">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</main>
</div>
</div>
<RoamDrawer :visible="windowVisible && ui.showRoamDrawer" />
<PlayerBar v-if="player.currentSong" />
<ToastContainer />
<UpdateDialog
:visible="updater.updateAvailable.value && !!updater.updateInfo.value"
:info="{ version: updater.updateInfo.value?.version || '', date: updater.updateInfo.value?.date ?? null, body: updater.updateInfo.value?.body ?? null, currentVersion: updater.currentVersion.value }"
:downloading="updater.downloading.value"
:download-progress="updater.downloadProgress.value"
@update="updater.downloadAndInstall()"
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
/>
<CloseModal
:visible="showCloseModal"
@confirm="handleCloseAction"
@cancel="showCloseModal = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue';
import { useRoute } from 'vue-router';
import { useUserStore } from './stores/user';
import { useSettingsStore, type CloseAction } from './stores/settings';
import { usePlayerStore } from './stores/player';
import { useUiStore } from './stores/ui';
import TopBar from './components/TopBar.vue';
import Sidebar from './components/Sidebar.vue';
import RoamDrawer from './components/RoamDrawer.vue';
import PlayerBar from './components/PlayerBar.vue';
import ToastContainer from './components/ToastContainer.vue';
import CloseModal from './components/CloseModal.vue';
import UpdateDialog from './components/UpdateDialog.vue';
import { useOnlineStatus } from './composables/useOnlineStatus';
import { showToast } from './composables/useToast';
import { useUpdater } from './composables/useUpdater';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen } from '@tauri-apps/api/event';
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
import { MusicApi, AudioApi, DeviceApi, AppApi } from './api';
import { hexToRgba } from './utils/color';
const userStore = useUserStore();
const player = usePlayerStore();
const ui = useUiStore();
const settings = useSettingsStore();
const updater = useUpdater();
const { isOnline } = useOnlineStatus();
watch(isOnline, (val, old) => {
if (val && !old) showToast('网络已恢复', 'success');
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
});
const showCloseModal = ref(false);
const windowVisible = ref(true);
// --- Keep-alive 缓存管理 ---
// 规则:30秒未访问的页面自动清除缓存;多级跳转时保留导航链上的页面;FavoriteSongs 常驻
const route = useRoute();
// 主滚动容器 ref(路由切换时重置滚动位置)
const mainScrollRef = ref<HTMLElement | null>(null);
// 路由切换时重置主滚动容器到顶部
// keep-alive 缓存会保留滚动位置,详情页等需要主动重置
watch(() => route.path, () => {
// 双重保险:nextTick + 延迟,确保 DOM 更新和缓存恢复后再滚动
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (mainScrollRef.value) {
mainScrollRef.value.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
});
});
});
const ROUTE_COMPONENT: Record<string, string> = {
home: 'HomeView', discover: 'DiscoverView', search: 'DiscoverView',
favorites: 'FavoriteSongsView', daily: 'DailySongsView',
'local-music': 'LocalMusicView', 'downloaded-music': 'DownloadedMusicView',
'cloud-music': 'CloudMusicView',
playlist: 'PlaylistDetailView', artist: 'ArtistDetailView', album: 'AlbumDetailView',
};
const ALL_CACHEABLE = [...new Set(Object.values(ROUTE_COMPONENT))];
const PERMANENT = new Set(['FavoriteSongsView']);
const CACHE_TTL = 300_000;
const lastActivatedAt: Record<string, number> = {};
const navStack = ref<string[]>([]);
const currentComp = ref('');
for (const name of ALL_CACHEABLE) lastActivatedAt[name] = Date.now();
watch(() => route.name, (newName, oldName) => {
// 离开旧页面时刷新其计时(30s 从离开时算起)
const oldComp = ROUTE_COMPONENT[oldName as string];
if (oldComp) lastActivatedAt[oldComp] = Date.now();
const comp = ROUTE_COMPONENT[newName as string];
if (!comp) return;
currentComp.value = comp;
lastActivatedAt[comp] = Date.now();
const idx = navStack.value.indexOf(comp);
if (idx !== -1) {
// 返回:截断到该位置
navStack.value = navStack.value.slice(0, idx + 1);
} else {
navStack.value.push(comp);
}
}, { immediate: true });
function computeInclude(): string[] {
const now = Date.now();
const include = new Set<string>(PERMANENT);
if (currentComp.value) include.add(currentComp.value);
for (const name of navStack.value) include.add(name);
for (const name of ALL_CACHEABLE) {
if (lastActivatedAt[name] && now - lastActivatedAt[name] < CACHE_TTL) include.add(name);
}
return [...include];
}
const keepAliveInclude = ref<string[]>(computeInclude());
let cleanupTimer: ReturnType<typeof setInterval>;
function startCleanup() { cleanupTimer = setInterval(() => { keepAliveInclude.value = computeInclude(); }, 10_000); }
function stopCleanup() { clearInterval(cleanupTimer); }
watch(() => settings.skin, () => {
settings.applySkin();
}, { immediate: true });
// 壁纸样式:通过 Rust 命令读取本地图片转 base64 data URL
const wallpaperDataUrl = ref('');
const wallpaperStyle = computed(() => {
if (!wallpaperDataUrl.value) return {};
const wp = settings.currentWallpaper;
return {
backgroundImage: `url(${wallpaperDataUrl.value})`,
filter: `blur(${wp.blur}px)`,
opacity: wp.opacity,
};
});
// 监听壁纸路径变化,异步加载图片
watch(() => settings.currentWallpaper.path, async (path) => {
if (!path) {
wallpaperDataUrl.value = '';
return;
}
try {
wallpaperDataUrl.value = await AppApi.readImageAsDataUrl(path);
} catch (e) {
console.error('加载壁纸失败:', e);
wallpaperDataUrl.value = '';
}
}, { immediate: true });
// --- 响应式侧边栏:窄屏自动切抽屉模式 ---
const DRAWER_BREAKPOINT = 768; // px
let savedSidebarMode: 'expanded' | 'collapsed' = 'expanded';
function applyResponsiveSidebar() {
const width = window.innerWidth;
if (width < DRAWER_BREAKPOINT) {
// 窄屏:切抽屉模式(不持久化,保留用户原选择)
if (ui.sidebarMode !== 'drawer') {
savedSidebarMode = ui.sidebarMode as 'expanded' | 'collapsed';
ui.setSidebarMode('drawer');
}
} else {
// 宽屏:恢复用户选择
if (ui.sidebarMode === 'drawer') {
ui.setSidebarMode(savedSidebarMode);
}
}
}
// 根容器背景:有壁纸时透明(遮罩层已保证文字可读),无壁纸时不透明
const rootBgStyle = computed(() => {
const wp = settings.currentWallpaper;
if (wp.path) {
return {}; // 透明,遮罩层统一处理
}
return {
backgroundColor: 'var(--c-bg)',
};
});
// 主题色遮罩层:用 --c-bg 的半透明版本覆盖壁纸,保证文字对比度
// 这是网易云式设计的核心:壁纸色调透出遮罩,文字始终清晰
const overlayStyle = computed(() => {
const bgColor = settings.currentColors.bg;
const rgba = hexToRgba(bgColor, 0.82);
return {
backgroundColor: rgba,
};
});
watch(() => userStore.isLoggedIn, (val) => {
if (val) {
player.loadLikedIds();
}
});
onMounted(() => {
document.addEventListener('contextmenu', (e) => e.preventDefault());
startCleanup();
// 响应式侧边栏
applyResponsiveSidebar();
window.addEventListener('resize', applyResponsiveSidebar);
AudioApi.stopAudio().catch(() => {});
if (userStore.isLoggedIn) {
player.loadLikedIds();
MusicApi.getLoginStatus().then(jsonStr => {
if (!jsonStr) return;
const data = JSON.parse(jsonStr);
if (data.account || data.profile) {
const profile = data.profile || data.account;
userStore.setUser({
userId: profile.userId,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
});
}
}).catch(() => {});
}
updater.checkForUpdate(true);
if (settings.outputDevice) {
DeviceApi.setOutputDevice(settings.outputDevice).catch(() => {});
}
});
const currentWindow = getCurrentWindow();
function closeWindow() {
if (settings.closeAction === 'ask') {
showCloseModal.value = true;
} else if (settings.closeAction === 'minimize') {
currentWindow.hide();
} else {
AppApi.exitApp();
}
}
function handleCloseAction(action: CloseAction, remember: boolean) {
if (remember) {
settings.setCloseAction(action);
}
showCloseModal.value = false;
if (action === 'minimize') {
currentWindow.hide();
} else {
AppApi.exitApp();
}
}
onMounted(() => {
const unlisten1 = listen('tray-play-pause', () => {
player.toggle();
});
const unlisten2 = listen('tray-next', () => {
player.next();
});
const unlisten3 = listen('tray-prev', () => {
player.prev();
});
const unlisten4 = listen('window-hidden', () => {
windowVisible.value = false;
keepAliveInclude.value = [];
stopCleanup();
});
const unlisten5 = listen('window-shown', () => {
windowVisible.value = true;
keepAliveInclude.value = computeInclude();
startCleanup();
});
onBeforeUnmount(() => {
unlisten1.then(fn => fn());
unlisten2.then(fn => fn());
unlisten3.then(fn => fn());
unlisten4.then(fn => fn());
unlisten5.then(fn => fn());
stopCleanup();
});
});
async function registerGlobalShortcuts() {
const globalActions: Record<string, () => void> = {
globalPlayPause: () => player.toggle(),
globalPrev: () => player.prev(),
globalNext: () => player.next(),
globalVolUp: () => player.adjustVolume(5),
globalVolDown: () => player.adjustVolume(-5),
};
for (const [id, action] of Object.entries(globalActions)) {
const key = settings.shortcuts[id]?.key;
if (!key) continue;
try { await unregister(key); } catch { /* 忽略 */ }
try {
await register(key, (event) => {
if (event.state === 'Pressed') action();
});
} catch { /* 忽略 */ }
}
}
watch(() => settings.shortcuts, () => {
registerGlobalShortcuts();
}, { deep: true });
onMounted(() => {
registerGlobalShortcuts();
});
function parseShortcutKey(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; code: string } {
const parts = combo.split('+');
return {
ctrl: parts.includes('Control'),
alt: parts.includes('Alt'),
shift: parts.includes('Shift'),
code: parts.find(p => !['Control', 'Alt', 'Shift'].includes(p)) || '',
};
}
onMounted(() => {
function onKeydown(e: KeyboardEvent) {
const el = e.target as HTMLElement;
const isEditable = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
if (e.code === 'Space' && !isEditable) {
e.preventDefault();
player.toggle();
}
const localActions: Record<string, () => void> = {
playPause: () => player.toggle(),
prev: () => player.prev(),
next: () => player.next(),
volUp: () => player.adjustVolume(5),
volDown: () => player.adjustVolume(-5),
};
for (const [id, action] of Object.entries(localActions)) {
const key = settings.shortcuts[id]?.key;
if (!key) continue;
const parsed = parseShortcutKey(key);
const ctrlMatch = parsed.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
const altMatch = parsed.alt ? e.altKey : !e.altKey;
const shiftMatch = parsed.shift ? e.shiftKey : !e.shiftKey;
if (ctrlMatch && altMatch && shiftMatch && e.code === parsed.code) {
e.preventDefault();
action();
return;
}
}
}
window.addEventListener('keydown', onKeydown);
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
});
});
</script>