feat: 添加本地音乐扫描播放功能

This commit is contained in:
alger
2026-02-06 17:49:14 +08:00
parent 292751643f
commit 0e47c127fe
23 changed files with 1363 additions and 51 deletions
+358
View File
@@ -0,0 +1,358 @@
<template>
<div class="local-music-page h-full w-full bg-white dark:bg-black transition-colors duration-500">
<n-scrollbar class="h-full">
<div class="local-music-content pb-32">
<!-- Hero Section -->
<section class="hero-section relative overflow-hidden rounded-tl-2xl">
<!-- 背景模糊效果 -->
<div class="hero-bg absolute inset-0 -top-20">
<div
class="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-primary/10 blur-3xl opacity-50 dark:opacity-30"
></div>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white dark:via-black/80 dark:to-black"
></div>
</div>
<!-- Hero 内容 -->
<div class="hero-content relative z-10 px-4 md:px-8 pt-10 pb-8">
<div class="flex flex-col md:flex-row gap-8 items-center md:items-end">
<div class="cover-wrapper relative group">
<div
class="cover-container relative w-32 h-32 md:w-40 md:h-40 rounded-2xl bg-primary/10 flex items-center justify-center shadow-2xl ring-4 ring-white/50 dark:ring-neutral-800/50"
>
<i class="ri-folder-music-fill text-6xl text-primary opacity-80" />
</div>
</div>
<div class="info-content text-center md:text-left">
<div class="badge mb-3">
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 dark:bg-primary/20 text-primary text-xs font-semibold uppercase tracking-wider"
>
{{ t('localMusic.title') }}
</span>
</div>
<h1
class="text-3xl md:text-4xl lg:text-5xl font-bold text-neutral-900 dark:text-white tracking-tight"
>
{{ t('localMusic.title') }}
</h1>
<p class="mt-4 text-sm md:text-base text-neutral-500 dark:text-neutral-400">
{{ t('localMusic.songCount', { count: localMusicStore.musicList.length }) }}
</p>
</div>
</div>
</div>
</section>
<!-- Action Bar (Sticky) -->
<section
class="action-bar sticky top-0 z-20 px-4 md:px-8 py-3 md:py-4 bg-white/80 dark:bg-black/80 backdrop-blur-xl border-b border-neutral-100 dark:border-neutral-800/50"
>
<div class="flex items-center justify-between gap-4">
<!-- 左侧搜索框 -->
<div class="flex-1 max-w-xs">
<n-input
v-model:value="searchKeyword"
:placeholder="t('localMusic.search')"
clearable
size="small"
round
>
<template #prefix>
<i class="ri-search-line text-neutral-400" />
</template>
</n-input>
</div>
<!-- 右侧操作按钮 -->
<div class="flex items-center gap-3">
<!-- 播放全部按钮 -->
<button
v-if="filteredList.length > 0"
class="action-btn-pill flex items-center gap-2 px-4 py-2 rounded-full font-semibold text-sm transition-all bg-primary text-white hover:bg-primary/90"
@click="handlePlayAll"
>
<i class="ri-play-fill text-lg" />
<span class="hidden md:inline">{{ t('localMusic.playAll') }}</span>
</button>
<!-- 扫描按钮 -->
<button
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
:disabled="localMusicStore.scanning"
@click="handleScan"
>
<i
class="ri-refresh-line text-lg"
:class="{ 'animate-spin': localMusicStore.scanning }"
/>
</button>
<!-- 添加文件夹按钮 -->
<button
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="handleAddFolder"
>
<i class="ri-folder-add-line text-lg" />
</button>
<!-- 文件夹管理按钮 -->
<button
v-if="localMusicStore.folderPaths.length > 0"
class="action-btn-icon w-10 h-10 rounded-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-900 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-all"
@click="showFolderManager = true"
>
<i class="ri-folder-settings-line text-lg" />
</button>
</div>
</div>
</section>
<!-- 扫描进度提示 -->
<section v-if="localMusicStore.scanning" class="px-4 md:px-8 mt-6">
<div
class="flex items-center gap-4 p-4 rounded-2xl bg-primary/5 dark:bg-primary/10 border border-primary/20"
>
<n-spin size="small" />
<div>
<p class="text-sm font-medium text-neutral-900 dark:text-white">
{{ t('localMusic.scanning') }}
</p>
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{{ t('localMusic.songCount', { count: localMusicStore.scanProgress }) }}
</p>
</div>
</div>
</section>
<!-- 歌曲列表 -->
<section class="list-section px-4 md:px-8 mt-6">
<!-- 空状态 -->
<div
v-if="!localMusicStore.scanning && filteredList.length === 0"
class="empty-state py-20 text-center"
>
<i class="ri-folder-music-fill text-5xl mb-4 text-neutral-200 dark:text-neutral-800" />
<p class="text-neutral-400">{{ t('localMusic.emptyState') }}</p>
<button
class="mt-6 px-6 py-2 rounded-full bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-all"
@click="handleAddFolder"
>
<i class="ri-folder-add-line mr-2" />
{{ t('localMusic.scanFolder') }}
</button>
</div>
<!-- 虚拟列表 -->
<div v-else-if="filteredList.length > 0" class="song-list-container">
<n-virtual-list
class="song-virtual-list"
style="max-height: calc(100vh - 280px)"
:items="filteredSongResults"
:item-size="70"
item-resizable
key-field="id"
>
<template #default="{ item, index }">
<div>
<song-item :index="index" :item="item" @play="handlePlaySong" />
<!-- 列表末尾留白 -->
<div v-if="index === filteredSongResults.length - 1" class="h-36"></div>
</div>
</template>
</n-virtual-list>
</div>
</section>
</div>
</n-scrollbar>
<!-- 文件夹管理抽屉 -->
<n-drawer v-model:show="showFolderManager" :width="400" placement="right">
<n-drawer-content :title="t('localMusic.removeFolder')" closable>
<div class="space-y-3 py-4">
<div
v-for="folder in localMusicStore.folderPaths"
:key="folder"
class="flex items-center justify-between p-3 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-100 dark:border-neutral-800"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<i class="ri-folder-line text-lg text-primary flex-shrink-0" />
<span class="text-sm text-neutral-700 dark:text-neutral-300 truncate">{{
folder
}}</span>
</div>
<button
class="w-8 h-8 rounded-full flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-500/10 transition-all flex-shrink-0 ml-2"
@click="handleRemoveFolder(folder)"
>
<i class="ri-delete-bin-line" />
</button>
</div>
<!-- 空文件夹列表 -->
<div v-if="localMusicStore.folderPaths.length === 0" class="text-center py-8">
<i class="ri-folder-line text-4xl text-neutral-200 dark:text-neutral-800" />
<p class="text-sm text-neutral-400 mt-2">{{ t('localMusic.emptyState') }}</p>
</div>
</div>
<template #footer>
<n-button type="primary" block @click="handleAddFolder">
<template #icon>
<i class="ri-folder-add-line" />
</template>
{{ t('localMusic.scanFolder') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</div>
</template>
<script setup lang="ts">
import { createDiscreteApi } from 'naive-ui';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import SongItem from '@/components/common/SongItem.vue';
import { useLocalMusicStore } from '@/store/modules/localMusic';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/types/music';
import { filterByKeyword, toSongResult } from '@/utils/localMusicUtils';
// ==================== Stores ====================
const { t } = useI18n();
const { message } = createDiscreteApi(['message']);
const localMusicStore = useLocalMusicStore();
const playerStore = usePlayerStore();
// ==================== State ====================
/** 搜索关键词 */
const searchKeyword = ref('');
/** 文件夹管理抽屉是否显示 */
const showFolderManager = ref(false);
// ==================== Computed ====================
/** 根据搜索关键词过滤后的本地音乐列表 */
const filteredList = computed(() => {
return filterByKeyword(localMusicStore.musicList, searchKeyword.value);
});
/** 将过滤后的列表转换为 SongResult[] 供 SongItem 使用 */
const filteredSongResults = computed(() => {
return filteredList.value.map(toSongResult);
});
// ==================== Methods ====================
/**
* 选择并添加文件夹
* 调用系统文件夹选择对话框
* dialog.showOpenDialog 返回 { canceled: boolean, filePaths: string[] }
*/
async function handleAddFolder(): Promise<void> {
try {
const result = await window.electron.ipcRenderer.invoke('select-directory');
if (result && !result.canceled && result.filePaths?.length > 0) {
localMusicStore.addFolder(result.filePaths[0]);
// 添加文件夹后自动触发扫描
await localMusicStore.scanFolders();
}
} catch (error) {
console.error('选择文件夹失败:', error);
message.error(String(error));
}
}
/**
* 移除文件夹
* @param folder 要移除的文件夹路径
*/
function handleRemoveFolder(folder: string): void {
localMusicStore.removeFolder(folder);
}
/**
* 触发扫描
*/
async function handleScan(): Promise<void> {
if (localMusicStore.folderPaths.length === 0) {
// 没有配置文件夹时,引导用户先添加文件夹
await handleAddFolder();
return;
}
await localMusicStore.scanFolders();
}
/**
* 播放单曲
* SongItem 内部已通过 playMusicEvent 调用 playerStore.setPlay 触发播放
* 此处只需设置播放列表上下文,确保上下一首切换正常
* @param song SongItem 组件 emit 的 SongResult 对象
*/
async function handlePlaySong(_song: SongResult): Promise<void> {
try {
// 设置播放列表上下文,确保上下一首切换正常
playerStore.setPlayList(filteredSongResults.value);
} catch (error) {
console.error('播放本地音乐失败:', error);
}
}
/**
* 播放全部
* 将完整列表转换为 SongResult[] 后设置为播放列表并从第一首开始播放
*/
async function handlePlayAll(): Promise<void> {
if (filteredSongResults.value.length === 0) return;
try {
const firstSong = filteredSongResults.value[0];
const entry = filteredList.value[0];
// 检查第一首歌文件是否存在
const exists = await window.electron.ipcRenderer.invoke('check-file-exists', entry.filePath);
if (!exists) {
message.error(t('localMusic.fileNotFound'));
return;
}
// 设置播放列表并播放第一首
playerStore.setPlayList(filteredSongResults.value);
await playerStore.setPlay(firstSong);
} catch (error) {
console.error('播放全部失败:', error);
}
}
// ==================== Lifecycle ====================
onMounted(async () => {
// 进入页面时从 IndexedDB 缓存加载音乐列表
await localMusicStore.loadFromCache();
});
</script>
<style scoped>
/* 虚拟列表样式 */
.song-virtual-list {
@apply w-full;
}
.song-virtual-list :deep(.n-virtual-list__scroll) {
scrollbar-width: thin;
}
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar {
width: 6px;
}
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar-thumb {
@apply bg-neutral-300 dark:bg-neutral-700 rounded-full;
}
.song-virtual-list :deep(.n-virtual-list__scroll)::-webkit-scrollbar-track {
@apply bg-transparent;
}
</style>