Files
Nekosonic-Music/src/views/Settings.vue
Atdunbg 718d3ed641 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()
2026-05-15 02:24:48 +08:00

377 lines
14 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 max-w-2xl">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<h1 class="text-2xl font-bold mb-8">设置</h1>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">音质选择</p>
<p class="text-xs text-content-3 mt-0.5">更高音质需要 VIP 权限</p>
</div>
<CustomSelect v-model="settings.audioQuality" :options="qualityLabels" />
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">主题</p>
<p class="text-xs text-content-3 mt-0.5">切换应用主题</p>
</div>
<div class="flex bg-subtle rounded-lg p-0.5">
<button
v-for="t in themeOptions"
:key="t.value"
@click="settings.setTheme(t.value)"
class="px-3 py-1.5 rounded-md text-sm transition"
:class="settings.theme === t.value ? 'bg-muted text-content' : 'text-content-3 hover:text-content-2'"
>
{{ t.label }}
</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-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">关闭窗口时</p>
<p class="text-xs text-content-3 mt-0.5">点击关闭按钮的默认行为</p>
</div>
<CustomSelect v-model="closeActionValue" :options="closeActionLabels" />
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">下载</h2>
<div class="space-y-5">
<div>
<div class="flex items-center justify-between mb-2">
<div>
<p class="text-sm font-medium">下载路径</p>
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
</div>
</div>
<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="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">
<a @click.prevent="openUrl('https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music')"
class="flex items-center gap-4 p-4 bg-subtle rounded-xl hover:bg-muted transition cursor-pointer">
<img src="../assets/app-icon.png" class="w-12 h-12 rounded-xl flex-shrink-0" alt="Nekosonic" />
<div>
<p class="font-semibold">Nekosonic</p>
<p class="text-xs text-content-3 mt-0.5">版本 {{ appVersion }}</p>
</div>
</a>
<p class="text-xs text-content-3 leading-relaxed">
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验
</p>
<button
@click="checkUpdate"
: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="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 ? '获取中...' : '查看最新版日志' }}
</button>
<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, 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({
get: () => settings.closeAction,
set: (val: CloseAction) => settings.setCloseAction(val),
});
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 },
];
async function checkUpdate() {
checkingUpdate.value = true;
updateMessage.value = '';
try {
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>