5 Commits

Author SHA1 Message Date
cf21c96eaf 更新版本至 v0.4.1 2026-05-21 14:22:14 +08:00
987d34f58b 修改changelog 2026-05-18 15:58:56 +08:00
baa6235c56 添加设置音频输出选择 2026-05-18 15:52:51 +08:00
38c079ed5c 更新README 2026-05-16 15:29:58 +08:00
68e3b92a6a 设置页面自适应,添加查看最新版本日志按钮 2026-05-16 13:34:10 +08:00
9 changed files with 212 additions and 36 deletions

View File

@ -1,3 +1,8 @@
## v0.4.1
添加音频输出外设选择
## v0.4.0 ## v0.4.0
### ✨ 新功能 ### ✨ 新功能

View File

@ -1,25 +1,60 @@
# Nekosonic # Nekosonic
一款轻量的跨平台音乐播放器支持Windows/Linux系统,音源主要源自网易云音乐。 一款轻量的跨平台音乐播放器,支持 Windows / Linux / macOS,音源源自网易云音乐。
## ✨ 特性 ## ✨ 特性
- 🔴 网易云账号登录(扫码) ### 播放
- 🎵 多音质播放(标准 / 较高 / 极高 / 无损 / Hi-Res
- 📻 私人漫游,沉浸式全屏歌词体验 - 🎵 在线音乐播放,流式缓冲边下边播
- ❤️ 一键喜欢 / 取消喜欢 - 🎵 多音质选择(标准 / 较高 / 极高 HQ / 无损 SQ / Hi-Res
- 📋 歌单管理,收藏 / 取消收藏歌单 - 🔄 播放模式切换(列表循环 / 随机播放 / 单曲循环)
- ⏯ 播放控制(播放 / 暂停 / 上一首 / 下一首 / 进度跳转 / 音量调节)
- 📋 播放队列管理(查看队列 / 移除歌曲 / 清空队列)
- 📻 私人漫游 FM个性化推荐VIP 试听自动跳过)
- 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus
- 🔊 音频输出设备选择
### 发现与浏览
- 🔍 关键词搜索歌曲 + 热门搜索标签
- 📋 歌单浏览(推荐歌单 / 排行榜 / 用户歌单 / 收藏歌单)
- 📋 歌单详情(歌曲列表 + 收藏 / 取消收藏 + 歌单评论)
- 🎤 歌手详情(热门歌曲 / 专辑 / 简介)
- 💿 专辑详情(歌曲列表 + 播放全部)
- 📅 每日推荐歌曲 - 📅 每日推荐歌曲
- 🕐 本地播放历史记录
- 🔍 关键词搜索歌曲 ### 歌词与评论
- 🎤 实时滚动歌词
- 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度)
- 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页)
- 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞)
### 收藏与下载
- ❤️ 一键喜欢 / 取消喜欢(同步到网易云账号)
- ⬇️ 歌曲下载(带进度显示 / VIP 拦截 / 元数据保存)
- 🎵 本地音乐管理(列出 / 播放 / 删除 / 音频元数据与封面读取)
- 🕐 本地播放历史记录(最多 200 首)
### 账号
- 🔴 网易云账号登录(二维码扫码 / 手机号密码)
- 🔑 登录态持久化(重启后自动恢复)
### 系统与设置
- 📡 系统托盘(播放控制 / 显示窗口 / 退出)
- 🛡 单实例运行(防止重复启动)
- ⌨️ 自定义快捷键(应用内 + 系统全局)
- 🌚 Light / Dark Mode 主题切换 - 🌚 Light / Dark Mode 主题切换
- 🛠 更多特性添加中 - ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出)
- 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度)
- 📝 更新日志查看
## 📦️ 安装 ## 📦️ 安装
访问本项目的 [Releases](https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music/releases) 页面下载安装包。 访问本项目的 [Releases](https://github.com/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
## 💻 配置开发环境 ## 💻 配置开发环境
@ -55,13 +90,18 @@ npm run tauri build
## ☑️ Todo ## ☑️ Todo
- [x] 评论系统
- [x] 歌曲下载
- [x] 本地音乐管理
- [x] 歌手详情页
- [x] 专辑详情页
- [x] 自定义全局快捷键
- [x] 自动更新
- [ ] MV 播放 - [ ] MV 播放
- [ ] 音乐云盘 - [ ] 音乐云盘
- [ ] 评论系统
- [ ] 下载功能
- [ ] 自定义全局快捷键
- [ ] 歌词翻译 - [ ] 歌词翻译
- [ ] 更多主题 - [ ] 更多主题
- [ ] 桌面歌词
欢迎提 Issue 和 Pull request。 欢迎提 Issue 和 Pull request。
@ -71,7 +111,6 @@ npm run tauri build
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。 基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
## 致谢 ## 致谢
- [ncm-api-rs](https://crates.io/crates/ncm-api-rs) — 网易云音乐 API 的 Rust 封装 - [ncm-api-rs](https://crates.io/crates/ncm-api-rs) — 网易云音乐 API 的 Rust 封装

2
src-tauri/Cargo.lock generated
View File

@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "Nekosonic" name = "Nekosonic"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"cpal", "cpal",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "Nekosonic" name = "Nekosonic"
version = "0.4.0" version = "0.4.1"
description = "A Simple music app" description = "A Simple music app"
authors = ["atdunbg"] authors = ["atdunbg"]
edition = "2021" edition = "2021"

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic", "productName": "Nekosonic",
"version": "0.4.0", "version": "0.4.1",
"identifier": "com.atdunbg.Nekosonic", "identifier": "com.atdunbg.Nekosonic",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -440,6 +440,14 @@ onMounted(async () => {
} catch {} } catch {}
updater.checkForUpdate(true); updater.checkForUpdate(true);
// 恢复保存的输出设备设置
if(settings.outputDevice) {
try {
await invoke('set_output_device', { device: settings.outputDevice });
}
catch{}
}
}); });
const currentWindow = getCurrentWindow(); const currentWindow = getCurrentWindow();

View File

@ -2,22 +2,22 @@
<div class="relative" ref="container"> <div class="relative" ref="container">
<button <button
@click="toggle" @click="toggle"
class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]" class="flex items-center justify-between bg-subtle border border-line rounded-lg px-3 py-1.5 text-sm text-content outline-none transition min-w-[140px] max-w-[320px] hover:border-content-3 focus:border-accent focus:shadow-[0_0_0_2px_var(--c-accent-dim)]"
:class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }" :class="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
> >
<span>{{ currentLabel }}</span> <span class="truncate">{{ currentLabel }}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }"><polyline points="6 9 12 15 18 9"/></svg> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform flex-shrink-0 ml-2" :class="{ 'rotate-180': isOpen }"><polyline points="6 9 12 15 18 9"/></svg>
</button> </button>
<Transition name="dropdown"> <Transition name="dropdown">
<div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full overflow-hidden"> <div v-if="isOpen" class="absolute right-0 top-full mt-1 bg-surface border border-line rounded-lg shadow-xl z-50 py-1 min-w-full max-w-[360px] overflow-hidden">
<button <button
v-for="(label, key) in options" v-for="(label, key) in options"
:key="key" :key="key"
@click="select(key)" @click="select(key)"
class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between" class="w-full text-left px-3 py-2 text-sm transition flex items-center justify-between gap-2"
:class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'" :class="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
> >
<span>{{ label }}</span> <span class="truncate">{{ label }}</span>
<svg v-if="modelValue === key" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <svg v-if="modelValue === key" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</button> </button>
</div> </div>

View File

@ -41,6 +41,7 @@ interface SettingsData {
theme: ThemeMode; theme: ThemeMode;
closeAction: CloseAction; closeAction: CloseAction;
shortcuts: Record<string, ShortcutBinding>; shortcuts: Record<string, ShortcutBinding>;
outputDevice: string | null;
} }
function loadSettings(): SettingsData { function loadSettings(): SettingsData {
@ -54,6 +55,7 @@ function loadSettings(): SettingsData {
theme: parsed.theme || 'dark', theme: parsed.theme || 'dark',
closeAction: parsed.closeAction || 'ask', closeAction: parsed.closeAction || 'ask',
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) }, shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
outputDevice: parsed.outputDevice || null,
}; };
} }
} catch {} } catch {}
@ -63,6 +65,7 @@ function loadSettings(): SettingsData {
theme: 'dark', theme: 'dark',
closeAction: 'ask', closeAction: 'ask',
shortcuts: { ...defaultShortcuts }, shortcuts: { ...defaultShortcuts },
outputDevice: null,
}; };
} }
@ -74,6 +77,7 @@ export const useSettingsStore = defineStore('settings', () => {
const theme = ref<ThemeMode>(saved.theme); const theme = ref<ThemeMode>(saved.theme);
const closeAction = ref<CloseAction>(saved.closeAction || 'ask'); const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts); const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
const outputDevice = ref<string | null>(saved.outputDevice);
function setAudioQuality(q: AudioQuality) { function setAudioQuality(q: AudioQuality) {
audioQuality.value = q; audioQuality.value = q;
@ -99,21 +103,27 @@ export const useSettingsStore = defineStore('settings', () => {
shortcuts.value = { ...defaultShortcuts }; shortcuts.value = { ...defaultShortcuts };
} }
function setOutputDevice(device: string | null) {
outputDevice.value = device;
}
function resetAll() { function resetAll() {
audioQuality.value = 'standard'; audioQuality.value = 'standard';
downloadPath.value = ''; downloadPath.value = '';
theme.value = 'dark'; theme.value = 'dark';
closeAction.value = 'ask'; closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts }; shortcuts.value = { ...defaultShortcuts };
outputDevice.value = null;
} }
watch([audioQuality, downloadPath, theme, closeAction, shortcuts], () => { watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice], () => {
const data: SettingsData = { const data: SettingsData = {
audioQuality: audioQuality.value, audioQuality: audioQuality.value,
downloadPath: downloadPath.value, downloadPath: downloadPath.value,
theme: theme.value, theme: theme.value,
closeAction: closeAction.value, closeAction: closeAction.value,
shortcuts: shortcuts.value, shortcuts: shortcuts.value,
outputDevice: outputDevice.value,
}; };
localStorage.setItem('app_settings', JSON.stringify(data)); localStorage.setItem('app_settings', JSON.stringify(data));
}, { deep: true }); }, { deep: true });
@ -124,10 +134,12 @@ export const useSettingsStore = defineStore('settings', () => {
theme, theme,
closeAction, closeAction,
shortcuts, shortcuts,
outputDevice,
setAudioQuality, setAudioQuality,
setDownloadPath, setDownloadPath,
setTheme, setTheme,
setCloseAction, setCloseAction,
setOutputDevice,
setShortcut, setShortcut,
resetShortcuts, resetShortcuts,
resetAll, resetAll,

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="p-8 text-content max-w-2xl"> <div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition"> <button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回 返回
</button> </button>
@ -8,6 +8,14 @@
<section class="mb-8"> <section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2> <h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
<div class="space-y-5"> <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="selectedDevice" :options="deviceOptions" />
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium">音质选择</p> <p class="text-sm font-medium">音质选择</p>
@ -157,15 +165,25 @@
<p class="text-xs text-content-3 leading-relaxed"> <p class="text-xs text-content-3 leading-relaxed">
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验 Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验
</p> </p>
<button <div class="flex items-center gap-2">
@click="handleCheckUpdate" <button
:disabled="updater.checking.value" @click="handleCheckUpdate"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition" :disabled="updater.checking.value"
> class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
<svg v-if="!updater.checking.value" 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> <svg v-if="!updater.checking.value" 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>
{{ updater.checking.value ? '检查中...' : '检查更新' }} <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>
</button> {{ updater.checking.value ? '检查中...' : '检查更新' }}
</button>
<button
@click="fetchChangelog"
:disabled="fetchingChangelog"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg 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"/></svg>
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
</button>
</div>
<p v-if="updater.error.value" class="text-xs text-content-3">{{ updater.error.value }}</p> <p v-if="updater.error.value" class="text-xs text-content-3">{{ updater.error.value }}</p>
</div> </div>
</section> </section>
@ -188,6 +206,36 @@
</div> </div>
</Transition> </Transition>
<Transition name="fade">
<div v-if="showChangelogModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showChangelogModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[480px] 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="changelogRelease" class="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/15 text-accent-text">v{{ changelogRelease.tag_name?.replace('v', '') }}</span>
</div>
<p v-if="changelogRelease?.published_at" class="text-xs text-content-3 mt-1">{{ formatDate(changelogRelease.published_at) }}</p>
</div>
<div v-if="changelogRelease?.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">{{ changelogRelease.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="showChangelogModal = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
关闭
</button>
<button v-if="changelogRelease?.html_url" @click="openUrl(changelogRelease.html_url)"
class="flex-1 py-2 rounded-lg bg-accent/20 hover:bg-accent/30 text-accent-text text-sm font-medium transition">
GitHub 中查看
</button>
</div>
</div>
</div>
</Transition>
</div> </div>
</template> </template>
@ -206,13 +254,45 @@ const settings = useSettingsStore();
const { showToast } = useToast(); const { showToast } = useToast();
const updater = useUpdater(); const updater = useUpdater();
const devices = ref<string[]>([]);
const deviceOptions = computed(() => {
const options: Record<string, string> = { '': '跟随系统默认' };
for (const name of devices.value) {
options[name] = name;
}
return options;
});
const selectedDevice = computed({
get: () => settings.outputDevice || '',
set: (val: string) => {
const device = val === '' ? null : val;
settings.setOutputDevice(device);
invoke('set_output_device', { device }).then(() => {
showToast(device ? `已切换到: ${device}` : '已切换到系统默认', 'success');
}).catch((e) => {
console.error('切换设备失败: ', e);
showToast('切换设备失败', 'error');
});
}
});
async function loadDevices() {
try {
devices.value = await invoke<string[]>('get_output_devices');
} catch (e) {
console.error('获取设备失败: ', e);
}
}
const appVersion = ref(''); const appVersion = ref('');
const defaultDownloadPath = ref(''); const defaultDownloadPath = ref('');
onMounted(async () => { onMounted(async () => {
appVersion.value = await getVersion(); appVersion.value = await getVersion();
try { try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path'); defaultDownloadPath.value = await invoke<string>('get_default_download_path');
} catch {} } catch { }
loadDevices();
}); });
const closeActionValue = computed({ const closeActionValue = computed({
@ -249,6 +329,38 @@ async function handleCheckUpdate() {
} }
} }
const fetchingChangelog = ref(false);
const changelogRelease = ref<any>(null);
const showChangelogModal = ref(false);
async function fetchChangelog() {
fetchingChangelog.value = true;
try {
const resp = await fetch('https://api.github.com/repos/atdunbg/Nekosonic-Music/releases?per_page=1');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const releases = await resp.json();
if (releases && releases.length > 0) {
changelogRelease.value = releases[0];
showChangelogModal.value = true;
} else {
showToast('暂无发布版本', 'info');
}
} catch (e: any) {
showToast(`获取失败: ${e}`, 'error');
} finally {
fetchingChangelog.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); const recordingId = ref<string | null>(null);
function formatShortcut(key: string): string { function formatShortcut(key: string): string {