feat: 内置酷狗搜索/排行榜/热门搜索 + 内嵌歌词提取ID3v2 USLT/SYLT

This commit is contained in:
miao-moe
2026-06-20 12:47:40 +08:00
parent b3cb3f1faa
commit da604cd842
76 changed files with 21349 additions and 0 deletions

View File

@@ -0,0 +1,344 @@
<template>
<div class="view-container local-view">
<h1 class="view-title">本地音乐</h1>
<!-- Toolbar -->
<div class="toolbar">
<button class="action-btn" @click="selectFolder" :disabled="scanning" v-if="supportsFolder">
<Icon :icon="scanning ? 'lucide:loader-2' : 'lucide:folder-open'" width="18" height="18" />
{{ scanning ? '扫描中...' : '选择文件夹' }}
</button>
<button class="action-btn" @click="selectFiles" :disabled="scanning">
<Icon icon="lucide:file-plus" width="18" height="18" />
选择文件
</button>
<button class="action-btn secondary" @click="clearAll" v-if="tracks.length > 0">
<Icon icon="lucide:trash-2" width="18" height="18" />
清空列表
</button>
<span class="track-count" v-if="tracks.length > 0">{{ tracks.length }} 首歌曲</span>
</div>
<!-- Progress -->
<div class="progress-bar" v-if="scanProgress > 0 && scanProgress < 100">
<div class="progress-fill" :style="{ width: scanProgress + '%' }"></div>
</div>
<p class="scan-status" v-if="scanMessage">{{ scanMessage }}</p>
<!-- Song list -->
<div class="track-list" v-if="tracks.length > 0">
<div
v-for="(track, idx) in tracks"
:key="track.id"
class="track-item"
:class="{ active: currentTrackId === track.id }"
@dblclick="playTrack(idx)"
>
<span class="track-idx">{{ idx + 1 }}</span>
<div class="track-info">
<span class="track-name">{{ track.name }}</span>
<span class="track-artist">{{ track.artist }}</span>
</div>
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
<button class="play-btn" @click="playTrack(idx)" title="播放">
<Icon icon="lucide:play" width="16" height="16" />
</button>
</div>
</div>
<!-- Empty state -->
<div class="empty-state" v-else-if="!scanning">
<div class="icon-box">
<Icon icon="lucide:music" width="48" height="48" />
</div>
<p>{{ scanned ? '未找到音频文件' : '暂无本地音乐' }}</p>
<p class="hint">选择包含音乐文件的文件夹或直接选择文件</p>
</div>
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
multiple
accept="audio/*"
style="display:none"
@change="onFileSelected"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player';
import { scanFile, scanDirectory, loadTracks, saveTracks, clearSavedTracks, supportsDirectoryPicker, trackToSong, formatDuration } from '../utils/localMusic';
import type { ScannedTrack } from '../utils/localMusic';
const player = usePlayerStore();
const fileInput = ref<HTMLInputElement | null>(null);
const tracks = ref<ScannedTrack[]>([]);
const scanning = ref(false);
const scanProgress = ref(0);
const scanMessage = ref('');
const scanned = ref(false);
const supportsFolder = supportsDirectoryPicker();
const currentTrackId = ref('');
onMounted(async () => {
const saved = await loadTracks();
if (saved.length > 0) {
tracks.value = saved as ScannedTrack[];
scanned.value = true;
}
});
async function selectFolder() {
try {
const dirHandle = await (window as any).showDirectoryPicker();
scanning.value = true;
scanProgress.value = 5;
scanMessage.value = '正在扫描文件夹...';
const found = await scanDirectory(dirHandle, (name) => {
scanMessage.value = `扫描: ${name}`;
});
scanProgress.value = 90;
scanMessage.value = `找到 ${found.length} 首歌曲`;
if (found.length > 0) {
tracks.value = found;
await saveTracks(found);
scanned.value = true;
}
scanProgress.value = 100;
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 2000);
} catch (err: any) {
if (err?.name !== 'AbortError' && err?.name !== 'SecurityError') {
console.warn('选择文件夹失败:', err);
}
scanMessage.value = '';
} finally {
scanning.value = false;
}
}
function selectFiles() {
fileInput.value?.click();
}
async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
scanning.value = true;
scanProgress.value = 10;
scanMessage.value = `处理 ${input.files.length} 个文件...`;
const found: ScannedTrack[] = [];
let done = 0;
for (const file of Array.from(input.files)) {
const track = await scanFile(file);
if (track) found.push(track);
done++;
scanProgress.value = 10 + Math.round((done / input.files.length) * 80);
scanMessage.value = `处理: ${file.name}`;
}
if (found.length > 0) {
tracks.value = found;
await saveTracks(found);
scanned.value = true;
}
scanProgress.value = 100;
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 1000);
scanning.value = false;
input.value = '';
}
function playTrack(idx: number) {
const track = tracks.value[idx];
if (!track) return;
currentTrackId.value = track.id;
const song = trackToSong(track);
song.url = URL.createObjectURL(track.file);
const list = tracks.value.map(t => {
const s = trackToSong(t);
s.url = URL.createObjectURL(t.file);
return s;
});
player.playFromList(song, list);
}
async function clearAll() {
tracks.value = [];
scanned.value = false;
await clearSavedTracks();
}
</script>
<style scoped>
.view-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: var(--radius-full);
font-weight: 600;
font-size: 0.9rem;
border: none;
cursor: pointer;
background: var(--color-accent);
color: var(--color-bg-primary);
transition: opacity 0.2s;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn:hover:not(:disabled) {
opacity: 0.85;
}
.action-btn.secondary {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.track-count {
margin-left: auto;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.progress-bar {
height: 4px;
background: var(--color-bg-secondary);
border-radius: 2px;
margin-bottom: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-accent);
border-radius: 2px;
transition: width 0.3s ease;
}
.scan-status {
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 8px;
}
.track-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.track-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: default;
transition: background 0.15s;
gap: 12px;
}
.track-item:hover {
background: var(--color-bg-secondary);
}
.track-item.active {
background: var(--color-bg-secondary);
color: var(--color-accent);
}
.track-idx {
width: 24px;
text-align: right;
color: var(--color-text-muted);
font-size: 0.85rem;
flex-shrink: 0;
}
.track-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.track-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-artist {
font-size: 0.8rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-duration {
color: var(--color-text-muted);
font-size: 0.85rem;
flex-shrink: 0;
}
.play-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.play-btn:hover {
color: var(--color-accent);
background: var(--color-bg-tertiary, rgba(128,128,128,0.1));
}
.empty-state {
height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
.icon-box {
background-color: var(--color-bg-secondary);
padding: 24px;
border-radius: 50%;
margin-bottom: 24px;
}
.hint {
font-size: 0.85rem;
margin-top: 8px;
opacity: 0.7;
}
</style>