feat: v0.3.0 - 流式播放、本地音乐、下载系统、漫游修复

### 新功能
- 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
- 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」
- 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
- 封面补全:本地音乐缺少封面时自动从网易云 API 获取
- 更新信息:接入 Gitea Releases API,查看最新版更新日志

### 修复
- 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick)
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
- 修复 PlayerBar 无封面歌曲显示破损图片
- 修复下载路径修改后不生效(Rust serde camelCase 映射)
- 修复本地音乐始终只显示默认路径歌曲
- 修复下载完成提示弹出 4 次
- 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步)

### 优化
- PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示
- audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended
- player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步
- 切歌/停止时立即清除 tickInterval 防止重复触发 next()
This commit is contained in:
2026-05-15 02:24:48 +08:00
parent 02f7df4201
commit 718d3ed641
25 changed files with 2123 additions and 216 deletions

View File

@ -18,7 +18,7 @@
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
@click="player.playFromList(songs, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
>
@ -46,6 +46,11 @@
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
</div>
@ -56,9 +61,11 @@
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatDuration } from '../utils/format';
const player = usePlayerStore();
const download = useDownload();
const songs = ref<any[]>([]);
const loading = ref(true);

View File

@ -38,18 +38,23 @@
<div v-if="loading" class="text-content-2">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
v-for="(song, index) in results"
:key="song.id"
@click="playSong(song)"
@click="playSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-content-2">
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ song.name }}</p>
<p class="text-sm text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
</div>
@ -63,10 +68,12 @@ import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const download = useDownload();
const keyword = ref('');
const results = ref<any[]>([]);
@ -116,8 +123,15 @@ function searchTag(tag: string) {
handleSearch();
}
async function playSong(song: any) {
player.play(song);
async function playSong(_song: any, index: number) {
const normalized = results.value.map((s: any) => ({
id: s.id,
name: s.name,
ar: s.ar || s.artists || [],
al: s.al || s.album || { picUrl: '' },
dt: s.dt || 0,
}));
player.playFromList(normalized, index);
}
</script>

View File

@ -22,7 +22,7 @@
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
@click="player.playFromList(songs, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
>
<span class="text-xs text-content-3 w-6 text-right">{{ index + 1 }}</span>
@ -37,6 +37,11 @@
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
</div>
@ -48,11 +53,13 @@ import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { useDownload } from '../composables/useDownload';
import { normalizeSong } from '../utils/song';
import { formatDuration } from '../utils/format';
const player = usePlayerStore();
const userStore = useUserStore();
const download = useDownload();
const songs = ref<any[]>([]);
const loading = ref(true);

225
src/views/LocalMusic.vue Normal file
View File

@ -0,0 +1,225 @@
<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<button
v-if="songs.length"
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
</div>
<div v-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-3">
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id + '-' + index"
@click="playLocalSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
:class="{ 'bg-subtle': player.currentSong?.id === song.id }"
>
<span class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-muted">
<img v-if="song.cover" :src="song.cover" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.artist }}<template v-if="song.album"> · {{ song.album }}</template>
</p>
</div>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.duration) }}</span>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(song.id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
<Transition name="fade">
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
从磁盘中删除
</button>
</div>
</Transition>
</div>
</div>
</div>
<Transition name="fade">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
<p class="text-sm text-content-2 mb-5">确定要删除{{ deleteTarget?.name }}此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showDeleteConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="doDelete"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
删除
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore, type Song } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
const player = usePlayerStore();
const download = useDownload();
const settings = useSettingsStore();
interface LocalSong {
id: number;
name: string;
artist: string;
album: string;
duration: number;
cover: string | null;
filename: string;
fileSize: number;
path: string;
local: boolean;
}
const songs = ref<LocalSong[]>([]);
const loading = ref(true);
const showDeleteConfirm = ref(false);
const deleteTarget = ref<LocalSong | null>(null);
const openMenuId = ref<number | null>(null);
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
function closeMenu() {
openMenuId.value = null;
}
onMounted(() => { document.addEventListener('click', closeMenu); });
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
async function refresh() {
loading.value = true;
try {
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
songs.value = list;
fetchMissingCovers();
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function fetchMissingCovers() {
const missing = songs.value.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
if (missing.length === 0) return;
const ids = [...new Set(missing.map(s => s.id))];
try {
const jsonStr: string = await invoke('get_song_detail', { id: JSON.stringify(ids) });
const data = JSON.parse(jsonStr);
const detailMap = new Map<number, string>();
for (const s of data.songs || []) {
const url = s.al?.picUrl;
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
}
for (const song of missing) {
const url = detailMap.get(song.id);
if (url) song.cover = url;
}
} catch {}
}
onMounted(refresh);
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function formatDuration(ms: number): string {
if (!ms || ms === 0) return '--:--';
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
function toSong(local: LocalSong): Song {
return {
id: local.id,
name: local.name,
ar: local.artist ? [{ name: local.artist }] : [],
al: { picUrl: local.cover || '', name: local.album || undefined },
dt: local.duration || undefined,
artists: local.artist ? [{ name: local.artist }] : [],
album: { picUrl: local.cover || undefined, name: local.album || undefined },
duration: local.duration || undefined,
localPath: local.path,
};
}
async function playLocalSong(_song: LocalSong, index: number) {
const normalized = songs.value.map(s => toSong(s));
player.playFromList(normalized, index);
}
function confirmDelete(song: LocalSong) {
openMenuId.value = null;
deleteTarget.value = song;
showDeleteConfirm.value = true;
}
async function doDelete() {
if (!deleteTarget.value) return;
try {
await invoke('delete_local_song', { query: { id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null } });
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
download.localSongIds.delete(deleteTarget.value.id);
showToast('已删除', 'success');
} catch (e) {
showToast('删除失败', 'error');
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -76,6 +76,11 @@
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
</div>
@ -88,12 +93,14 @@ import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { useDownload } from '../composables/useDownload';
import { showToast } from '../composables/useToast';
import { formatDuration, formatPlayCount } from '../utils/format';
const route = useRoute();
const player = usePlayerStore();
const userStore = useUserStore();
const download = useDownload();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
@ -136,7 +143,8 @@ function isCurrentSong(songId: number): boolean {
}
async function playSingle(song: any) {
player.play(song);
const idx = songs.value.findIndex((s: any) => s.id === song.id);
player.playFromList(songs.value, idx >= 0 ? idx : 0);
}
function playAll() {

View File

@ -9,7 +9,7 @@
<div
v-for="(song, index) in player.recentLocal"
:key="song.id"
@click="player.play(song)"
@click="player.playFromList(player.recentLocal, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
>
<span class="text-xs text-content-3 w-6 text-right">{{ index + 1 }}</span>
@ -24,6 +24,11 @@
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
</div>
</div>
@ -32,7 +37,9 @@
<script setup lang="ts">
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatDuration } from '../utils/format';
const player = usePlayerStore();
const download = useDownload();
</script>

View File

@ -12,9 +12,17 @@
<template v-else>
<img
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
v-if="coverUrl && !coverError"
:src="coverUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
@error="coverError = true"
/>
<div
v-else
class="w-80 h-80 rounded-3xl bg-muted flex items-center justify-center shadow-2xl mb-8"
>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-content-2 mb-8">
@ -46,12 +54,13 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { ref, computed, watch, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song';
const player = usePlayerStore();
const coverError = ref(false);
const currentSong = computed(() => {
if (player.isFmMode && player.currentSong) {
@ -60,6 +69,13 @@ const currentSong = computed(() => {
return null;
});
const coverUrl = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.al?.picUrl || currentSong.value.album?.picUrl || '';
});
watch(coverUrl, () => { coverError.value = false; });
const artists = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||

View File

@ -12,18 +12,23 @@
<div v-if="loading" class="text-content-2">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
v-for="(song, index) in results"
:key="song.id"
@click="playSong(song)"
@click="playSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-content-2">
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ song.name }}</p>
<p class="text-sm text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
</div>
@ -39,6 +44,7 @@ import { watch } from 'vue';
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useRouter } from 'vue-router';
const router = useRouter();
@ -47,6 +53,7 @@ const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const player = usePlayerStore();
const download = useDownload();
const route = useRoute();
watch(
@ -76,9 +83,16 @@ async function handleSearch() {
}
}
async function playSong(song: any) {
async function playSong(_song: any, index: number) {
try {
await player.play(song);
const normalized = results.value.map((s: any) => ({
id: s.id,
name: s.name,
ar: s.ar || s.artists || [],
al: s.al || s.album || { picUrl: '' },
dt: s.dt || 0,
}));
await player.playFromList(normalized, index);
} catch (e) {
alert('暂无播放源或需登录');
}

View File

@ -64,24 +64,85 @@
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
</div>
</div>
<div class="flex gap-2">
<input
v-model="downloadPathInput"
type="text"
placeholder="例如:~/Music/Nekosonic"
class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content placeholder-content-4 outline-none focus:border-accent/50 transition"
/>
<div class="flex gap-2 items-center">
<div class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content-2 truncate" :title="settings.downloadPath || defaultDownloadPath">
{{ settings.downloadPath || defaultDownloadPath }}
</div>
<button
@click="saveDownloadPath"
class="px-4 py-2 bg-accent-dim hover:bg-accent-dim text-accent-text rounded-lg text-sm transition"
@click="pickDownloadFolder"
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-accent/15 text-accent-text hover:bg-accent/25 active:scale-95"
>
保存
选择文件夹
</button>
<button
v-if="settings.downloadPath"
@click="clearDownloadPath"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-muted text-content-2 hover:bg-emphasis hover:text-content active:scale-95"
title="重置为默认路径"
>
重置
</button>
</div>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">快捷键</h2>
<div class="space-y-3">
<div
v-for="(sc, id) in settings.shortcuts"
:key="id"
class="flex items-center justify-between p-3 bg-subtle rounded-xl"
>
<div>
<p class="text-sm font-medium">{{ sc.label }}</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-if="sc.key !== defaultShortcuts[id]?.key"
@click="settings.setShortcut(id, defaultShortcuts[id].key)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
title="恢复默认"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button
@click="startRecording(id)"
class="px-3 py-1.5 rounded-lg text-sm transition min-w-[120px] text-center"
:class="recordingId === id ? 'bg-accent text-white' : 'bg-muted hover:bg-emphasis text-content-2'"
>
{{ recordingId === id ? '按下新快捷键...' : formatShortcut(sc.key) }}
</button>
</div>
</div>
<button
@click="resetShortcuts"
class="text-xs text-content-3 hover:text-danger transition"
>
恢复默认快捷键
</button>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">其他</h2>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-subtle rounded-xl">
<div>
<p class="text-sm font-medium">恢复默认设置</p>
<p class="text-xs text-content-3 mt-0.5">重置所有设置为初始状态</p>
</div>
<button
@click="handleResetAll"
class="px-3 py-1.5 rounded-lg text-sm bg-muted hover:bg-emphasis text-danger transition"
>
重置
</button>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">关于</h2>
<div class="space-y-4">
@ -101,30 +162,84 @@
:disabled="checkingUpdate"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg v-if="!checkingUpdate" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.66 0 3-4.03 3-9s-1.34-9-3-9m0 18c-1.66 0-3-4.03-3-9s1.34-9 3-9m-9 9a9 9 0 019-9"/></svg>
<svg v-if="!checkingUpdate" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
{{ checkingUpdate ? '检查中...' : '检查更新(暂未实现)' }}
{{ checkingUpdate ? '获取中...' : '查看最新版日志' }}
</button>
<p v-if="updateMessage" class="text-xs" :class="updateMessageClass">{{ updateMessage }}</p>
<p v-if="updateMessage && !latestRelease" class="text-xs" :class="updateMessageClass">{{ updateMessage }}</p>
</div>
</section>
<Transition name="fade">
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showResetConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="confirmResetAll"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
确认重置
</button>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="showUpdateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showUpdateModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] max-h-[80vh] flex flex-col select-auto">
<div class="p-6 pb-4">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-content">最新版本日志</h2>
<span v-if="latestRelease" class="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/15 text-accent-text">v{{ latestRelease.tag_name?.replace('v', '') }}</span>
</div>
<p v-if="latestRelease?.published_at" class="text-xs text-content-3 mt-1">{{ formatDate(latestRelease.published_at) }}</p>
</div>
<div v-if="latestRelease?.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60">
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ latestRelease.body }}</div>
</div>
<div v-else class="px-6 pb-4">
<p class="text-sm text-content-3">暂无更新日志</p>
</div>
<div class="p-4 border-t border-line flex gap-3">
<button @click="showUpdateModal = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
关闭
</button>
<button v-if="latestRelease?.html_url" @click="openUrl(latestRelease.html_url)"
class="flex-1 py-2 rounded-lg bg-accent/20 hover:bg-accent/30 text-accent-text text-sm font-medium transition">
Gitea 中查看
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, type CloseAction } from '../stores/settings';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast';
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { open } from '@tauri-apps/plugin-dialog';
import CustomSelect from '../components/CustomSelect.vue';
const settings = useSettingsStore();
const { showToast } = useToast();
const appVersion = ref('');
const defaultDownloadPath = ref('');
onMounted(async () => {
appVersion.value = await getVersion();
try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
} catch {}
});
const closeActionValue = computed({
@ -132,33 +247,130 @@ const closeActionValue = computed({
set: (val: CloseAction) => settings.setCloseAction(val),
});
const downloadPathInput = ref(settings.downloadPath);
async function pickDownloadFolder() {
const selected = await open({
directory: true,
multiple: false,
title: '选择下载路径',
});
if (selected) {
settings.setDownloadPath(selected);
showToast('下载路径已更新', 'success');
}
}
function clearDownloadPath() {
settings.setDownloadPath('');
showToast('已重置为默认路径', 'success');
}
const checkingUpdate = ref(false);
const updateMessage = ref('');
const updateMessageClass = ref('text-content-2');
const latestRelease = ref<any>(null);
const showUpdateModal = ref(false);
const themeOptions = [
{ label: '深色', value: 'dark' as const },
{ label: '浅色', value: 'light' as const },
];
function saveDownloadPath() {
settings.setDownloadPath(downloadPathInput.value.trim());
showToast('下载路径已保存', 'success');
}
async function checkUpdate() {
checkingUpdate.value = true;
updateMessage.value = '';
try {
await new Promise(r => setTimeout(r, 1500));
updateMessage.value = '当前已是最新版本';
updateMessageClass.value = 'text-accent-text';
} catch {
updateMessage.value = '检查更新失败,请稍后重试';
const resp = await fetch('https://gitea.atdunbg.xyz/api/v1/repos/atdunbg/Nekosonic-Music/releases?limit=1&draft=false');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const releases = await resp.json();
if (releases && releases.length > 0) {
latestRelease.value = releases[0];
showUpdateModal.value = true;
} else {
updateMessage.value = '暂无发布版本';
updateMessageClass.value = 'text-content-3';
}
} catch (e: any) {
updateMessage.value = `获取失败: ${e}`;
updateMessageClass.value = 'text-danger';
} finally {
checkingUpdate.value = false;
}
}
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return dateStr;
}
}
const recordingId = ref<string | null>(null);
function formatShortcut(key: string): string {
return key
.replace('Control', 'Ctrl')
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace(/\+/g, ' + ');
}
function startRecording(id: string) {
recordingId.value = id;
}
function resetShortcuts() {
settings.resetShortcuts();
showToast('快捷键已恢复默认', 'success');
}
const showResetConfirm = ref(false);
function handleResetAll() {
showResetConfirm.value = true;
}
function confirmResetAll() {
settings.resetAll();
showResetConfirm.value = false;
showToast('已恢复默认设置', 'success');
}
function onRecordingKeydown(e: KeyboardEvent) {
if (!recordingId.value) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
recordingId.value = null;
return;
}
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push('Control');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
const ignoredKeys = ['Control', 'Alt', 'Shift', 'Meta'];
if (!ignoredKeys.includes(e.key)) {
parts.push(e.code);
}
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
const combo = parts.join('+');
settings.setShortcut(recordingId.value, combo);
recordingId.value = null;
}
}
onMounted(() => {
window.addEventListener('keydown', onRecordingKeydown, true);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onRecordingKeydown, true);
});
</script>