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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user