Files
Nekosonic-Music/src/views/LocalMusic.vue
Atdunbg dcfada6940 feat: 皮肤系统重构、seek暂停修复、本地音乐优化、外观一体化
- 重构皮肤系统:提取 skins.ts 管理预设皮肤,CSS 变量由 JS 动态设置

- 提取公共 color.ts 工具函数(hexToRgba/toHex),消除重复定义

- 修复 seek 时暂停状态丢失的 bug(后端 audio_paused 状态保留)

- 本地音乐页面:循环排序切换、三点菜单、打开所在文件夹

- 本地音乐文件夹管理:支持启用/禁用切换,兼容旧数据迁移

- 新增 show_item_in_folder 命令(Windows/macOS/Linux 跨平台)

- 外观一体化:有壁纸时 TitleBar/Sidebar 透明,PlayerBar 统一透明度+backdrop-blur

- 进度条外层直角、内层填充圆角

- 滚动条默认透明,悬停时显示

- 移除 PageHeader 粘性栏

- 内存优化:keep-alive TTL 5min、pageCache TTL 30min/上限30条、colorCache 上限200

- recentLocal 防抖写入、播放器 tick interval 500ms
2026-06-07 07:45:41 +08:00

232 lines
9.0 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>
<div class="p-8 text-content">
<PageHeader>
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<template #actions>
<button @click="cycleSort" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition-all flex items-center justify-center gap-1 whitespace-nowrap">
<IconArrowUpDown class="w-3 h-3" />
{{ sortLabel }}
</button>
<button @click="refresh" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">刷新</button>
<button @click="showFolderModal = true" class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition">扫描目录</button>
</template>
</PageHeader>
<div v-if="loading" class="space-y-1">
<div v-for="i in 6" :key="i" class="flex items-center gap-3 px-3 py-2">
<div class="w-12 h-12 bg-muted rounded animate-pulse flex-shrink-0"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-muted rounded w-2/3 animate-pulse"></div>
<div class="h-3 bg-muted rounded w-1/3 animate-pulse"></div>
</div>
</div>
</div>
<div v-else-if="settings.localMusicFolders.length === 0" class="text-content-3 py-4">
请先添加要扫描的文件夹
</div>
<div v-else-if="settings.enabledMusicPaths.length === 0" class="text-content-3 py-4">
请至少启用一个扫描文件夹
</div>
<div v-else-if="songs.length === 0" class="text-content-3">
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</div>
<div v-else class="space-y-2">
<SongListItem
v-for="(song, index) in sortedSongs"
:key="song.id + '-' + index"
:song="sortedNormalized[index]"
:index="index"
:is-current="player.currentSong?.id === song.id"
show-index
show-duration
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(sortedNormalized, index)"
>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
<div class="relative flex-shrink-0" :ref="(el: any) => menuRefs[song.id] = el">
<button @click.stop="toggleMenu(song.id)" class="text-content-3 hover:text-content transition p-1 rounded-md hover:bg-subtle">
<IconEllipsis class="w-4 h-4 fill-current" />
</button>
<div v-if="openMenuId === song.id"
class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-xl shadow-xl z-50 py-1 min-w-[140px]">
<button @click.stop="openFolder(song.path)" class="w-full flex items-center gap-2 px-3 py-2 text-sm text-content-2 hover:bg-subtle hover:text-content transition whitespace-nowrap">
<IconFolderOpen class="w-3.5 h-3.5" />
打开所在文件夹
</button>
</div>
</div>
</template>
</SongListItem>
</div>
<!-- 文件夹管理弹窗 -->
<Transition name="fade">
<div v-if="showFolderModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showFolderModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] p-6 select-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">扫描目录</h2>
<button @click="showFolderModal = false" class="text-content-3 hover:text-content transition">
<IconX class="w-5 h-5" />
</button>
</div>
<div v-if="settings.localMusicFolders.length === 0" class="text-sm text-content-3 py-4 text-center">
未添加任何文件夹
</div>
<div v-else class="space-y-1.5 max-h-60 overflow-y-auto mb-4">
<div v-for="folder in settings.localMusicFolders" :key="folder.path" class="flex items-center gap-2 px-3 py-2 bg-subtle rounded-lg group">
<button @click="settings.toggleLocalMusicFolder(folder.path)" class="flex-shrink-0" :title="folder.enabled ? '点击禁用' : '点击启用'">
<IconCheckSquare v-if="folder.enabled" class="w-4 h-4 text-accent-text" />
<IconSquare v-else class="w-4 h-4 text-content-4" />
</button>
<IconFolder class="w-4 h-4 text-content-3 flex-shrink-0" />
<span class="text-sm truncate flex-1" :class="folder.enabled ? 'text-content-2' : 'text-content-4 line-through'" :title="folder.path">{{ folder.path }}</span>
<button @click="settings.removeLocalMusicPath(folder.path)" class="text-content-4 hover:text-danger transition opacity-0 group-hover:opacity-100" title="移除">
<IconX class="w-4 h-4" />
</button>
</div>
</div>
<button @click="addFolder" class="w-full py-2.5 rounded-lg bg-accent/15 text-accent-text hover:bg-accent/25 text-sm font-medium transition">
添加文件夹
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, watch, onBeforeUnmount } from 'vue';
import { AppApi, DownloadApi } from '../api';
import { usePlayerStore } from '../stores/player';
import { useSettingsStore } from '../stores/settings';
import { pageCacheSet, pageCacheIsStale } from '../composables/usePageCache';
import { formatFileSize, localSongToSong, fetchMissingCovers, type LocalSong } from '../composables/useLocalMusic';
import { showToast } from '../composables/useToast';
import { open } from '@tauri-apps/plugin-dialog';
import SongListItem from '../components/SongListItem.vue';
import PageHeader from '../components/PageHeader.vue';
import IconFolder from '~icons/lucide/folder';
import IconFolderOpen from '~icons/lucide/folder-open';
import IconX from '~icons/lucide/x';
import IconArrowUpDown from '~icons/lucide/arrow-up-down';
import IconCheckSquare from '~icons/lucide/check-square';
import IconSquare from '~icons/lucide/square';
import IconEllipsis from '~icons/lucide/ellipsis';
defineOptions({ name: 'LocalMusicView' });
const player = usePlayerStore();
const settings = useSettingsStore();
const songs = ref<LocalSong[]>([]);
const loading = ref(true);
const showFolderModal = ref(false);
// 排序:点击循环切换
type SortKey = 'default' | 'name' | 'size';
const SORT_CYCLE: SortKey[] = ['default', 'name', 'size'];
const SORT_LABELS: Record<SortKey, string> = { default: '默认', name: '名称', size: '大小' };
const sortBy = ref<SortKey>('default');
const sortLabel = computed(() => SORT_LABELS[sortBy.value]);
function cycleSort() {
const idx = SORT_CYCLE.indexOf(sortBy.value);
sortBy.value = SORT_CYCLE[(idx + 1) % SORT_CYCLE.length];
}
const sortedSongs = computed(() => {
const list = [...songs.value];
if (sortBy.value === 'name') {
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
} else if (sortBy.value === 'size') {
list.sort((a, b) => b.fileSize - a.fileSize);
}
return list;
});
const sortedNormalized = computed(() => sortedSongs.value.map(localSongToSong));
// 三点菜单
const openMenuId = ref<number | null>(null);
const menuRefs: Record<number, HTMLElement | null> = {};
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
async function openFolder(path: string) {
openMenuId.value = null;
try {
await AppApi.showItemInFolder(path);
} catch (e: any) {
showToast(e.toString(), 'error');
}
}
function onClickOutside(e: MouseEvent) {
if (openMenuId.value !== null) {
const el = menuRefs[openMenuId.value];
if (el && !el.contains(e.target as Node)) {
openMenuId.value = null;
}
}
}
onMounted(() => document.addEventListener('click', onClickOutside));
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside));
async function addFolder() {
const selected = await open({
directory: true,
multiple: false,
title: '选择音乐文件夹',
});
if (selected) {
settings.addLocalMusicPath(selected);
}
}
async function refresh() {
const paths = settings.enabledMusicPaths;
if (paths.length === 0) {
songs.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
const list = await DownloadApi.scanLocalFolders(paths);
songs.value = list;
pageCacheSet('localMusic', list);
fetchMissingCovers(songs.value);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
onMounted(refresh);
onActivated(() => {
if (pageCacheIsStale('localMusic')) refresh();
});
watch(() => settings.enabledMusicPaths, () => { refresh(); }, { deep: true });
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>