345 lines
8.2 KiB
Vue
345 lines
8.2 KiB
Vue
<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>
|