6 Commits

Author SHA1 Message Date
970fb15f5a 重构播放列表为右侧弹出式
播放列表可以定位正在播放的歌曲位置
添加歌词翻译
新增快捷键 播放/暂停
重构主题设置,支持多种主题

修复评论playerbar查看点击后一直默认评论打开抽屉页面问题
2026-05-22 00:54:09 +08:00
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
15 changed files with 627 additions and 151 deletions

View File

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

View File

@ -1,25 +1,60 @@
# 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 主题切换
- 🛠 更多特性添加中
- ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出)
- 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度)
- 📝 更新日志查看
## 📦️ 安装
访问本项目的 [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
- [x] 评论系统
- [x] 歌曲下载
- [x] 本地音乐管理
- [x] 歌手详情页
- [x] 专辑详情页
- [x] 自定义全局快捷键
- [x] 自动更新
- [ ] MV 播放
- [ ] 音乐云盘
- [ ] 评论系统
- [ ] 下载功能
- [ ] 自定义全局快捷键
- [ ] 歌词翻译
- [ ] 更多主题
- [ ] 桌面歌词
欢迎提 Issue 和 Pull request。
@ -71,7 +111,6 @@ npm run tauri build
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
## 致谢
- [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]]
name = "Nekosonic"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"base64 0.22.1",
"cpal",

View File

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

View File

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

View File

@ -184,6 +184,12 @@
:class="roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80'">
评论
</button>
<button v-if="hasTranslation" @click="toggleTranslation"
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
:class="showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8l6 6"/><path d="M4 14l6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="M22 22l-5-10-5 10"/><path d="M14 18h6"/></svg>
</button>
</div>
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
@ -198,6 +204,7 @@
@mouseleave="roamLyricHovering = false"
>
{{ line.text }}
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
</p>
</div>
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
@ -305,7 +312,7 @@ function doSearch() {
if (q) router.push({ path: '/discover', query: { q } });
}
const { lyrics, currentLyricIdx } = useLyric();
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0);
@ -372,8 +379,8 @@ function getRoamLyricClass(idx: number): string {
return 'roam-lyric-active text-accent-text font-semibold text-xl';
}
if (diff === 1) return 'text-content/70 text-lg';
if (diff === 2) return 'text-content-2/50 text-base';
return 'text-content-3/35 text-base';
if (diff === 2) return 'text-content-2/50 text-[1rem]';
return 'text-content-3/35 text-[1rem]';
}
function seekToRoamLyric(time: number) {
@ -440,6 +447,14 @@ onMounted(async () => {
} catch {}
updater.checkForUpdate(true);
// 恢复保存的输出设备设置
if(settings.outputDevice) {
try {
await invoke('set_output_device', { device: settings.outputDevice });
}
catch{}
}
});
const currentWindow = getCurrentWindow();
@ -498,6 +513,7 @@ onMounted(() => {
async function registerGlobalShortcuts() {
const globalActions: Record<string, () => void> = {
globalPlayPause: () => player.toggle(),
globalPrev: () => player.prev(),
globalNext: () => player.next(),
globalVolUp: () => player.adjustVolume(5),
@ -543,6 +559,7 @@ onMounted(() => {
}
const localActions: Record<string, () => void> = {
playPause: () => player.toggle(),
prev: () => player.prev(),
next: () => player.next(),
volUp: () => player.adjustVolume(5),

View File

@ -2,22 +2,22 @@
<div class="relative" ref="container">
<button
@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 }"
>
<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>
</button>
<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
v-for="(label, key) in options"
:key="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'"
>
<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>
</button>
</div>

View File

@ -104,41 +104,99 @@
</div>
</div>
<Transition name="slide-up">
<div v-if="showQueuePanel"
class="border-t border-line bg-surface/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
<button @click="player.clearQueue()" class="text-xs text-danger hover:text-danger transition">清空</button>
</div>
<div class="space-y-1">
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
idx === player.currentIndex ? 'bg-accent-dim text-content' : 'hover:bg-subtle text-content-2',
]">
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-content-3 truncate">
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
<span v-if="i > 0" class="text-content-3">/</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
</p>
<Teleport to="body">
<Transition name="queue-fade">
<div v-if="showQueuePanel" class="fixed inset-0 z-[55] bg-black/40 backdrop-blur-[2px]" @click="showQueuePanel = false"></div>
</Transition>
<Transition name="queue-slide">
<div v-if="showQueuePanel"
class="fixed right-0 top-0 bottom-0 z-[56] w-[340px] bg-base/95 backdrop-blur border-l border-line flex flex-col shadow-2xl shadow-black/40">
<div class="px-5 pt-5 pb-3">
<div class="flex items-center justify-between">
<div>
<h3 class="text-[1rem] font-semibold text-content">播放列表</h3>
<p class="text-xs text-content-3 mt-0.5">{{ player.queue.length }} 首歌曲</p>
</div>
<div class="flex items-center gap-1">
<button @click="player.clearQueue()"
class="px-2.5 py-1 text-xs text-content-3 hover:text-danger hover:bg-danger-dim rounded-lg transition">
清空
</button>
<button @click="showQueuePanel = false"
class="w-7 h-7 flex items-center justify-center rounded-lg text-content-3 hover:text-content hover:bg-subtle transition">
<svg width="16" height="16" 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>
</div>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="text-content-3 hover:text-danger transition text-sm">
<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>
</div>
<div class="h-px mx-5 bg-line"></div>
<div ref="queueListEl" class="flex-1 overflow-y-auto px-3 py-2 relative">
<div v-if="player.queue.length === 0" class="flex flex-col items-center justify-center h-full text-content-4 gap-3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40">
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
<p class="text-sm">播放列表为空</p>
<p class="text-xs text-content-4">去发现好听的音乐吧</p>
</div>
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" :id="'queue-item-' + idx"
@click="playFromQueue(idx)" :class="[
'flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 group',
idx === player.currentIndex
? 'bg-muted'
: 'hover:bg-subtle',
]">
<div class="w-9 h-9 rounded-md overflow-hidden flex-shrink-0 relative">
<img v-if="song.al?.picUrl" :src="song.al.picUrl + '?param=80y80'" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-content-4"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<div v-if="idx === player.currentIndex"
class="absolute inset-0 bg-black/30 flex items-center justify-center">
<div class="flex items-end gap-[2px] h-3">
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
</div>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm truncate" :class="idx === player.currentIndex ? 'text-accent-text font-medium' : 'text-content-2'">
{{ song.name }}
</p>
<p class="text-xs text-content-3 truncate mt-0.5">
<template v-for="(a, i) in song.ar || []" :key="a.id || i">
<span v-if="i > 0" class="text-content-4">/</span>
<span>{{ a.name }}</span>
</template>
</p>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger-dim transition opacity-0 group-hover:opacity-100 flex-shrink-0">
<svg width="12" height="12" 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>
</div>
<div class="h-2"></div>
<button v-if="player.currentIndex >= 0 && player.queue.length > 0" v-show="!currentSongVisible"
@click="scrollToCurrent"
class="sticky bottom-3 float-right mr-1 w-9 h-9 flex items-center justify-center rounded-full bg-surface/90 backdrop-blur shadow-lg shadow-black/30 text-content-3 hover:text-accent-text hover:bg-accent-dim/50 transition-all duration-300"
title="定位到正在播放">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>
</button>
</div>
</div>
</div>
</Transition>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format';
@ -150,6 +208,8 @@ const router = useRouter();
const player = usePlayerStore();
const download = useDownload();
const showQueuePanel = ref(false);
const queueListEl = ref<HTMLElement | null>(null);
const currentSongVisible = ref(true);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
@ -241,6 +301,50 @@ function playFromQueue(index: number) {
player.playCurrent();
}
function scrollToCurrent() {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
let currentSongObserver: IntersectionObserver | null = null;
function setupCurrentSongObserver() {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
nextTick(() => {
const el = document.getElementById('queue-item-' + player.currentIndex);
if (!el || !queueListEl.value) return;
currentSongObserver = new IntersectionObserver(
([entry]) => { currentSongVisible.value = entry.isIntersecting; },
{ root: queueListEl.value, threshold: 0.5 }
);
currentSongObserver.observe(el);
});
}
watch(() => [showQueuePanel.value, player.currentIndex, player.queue.length], () => {
if (showQueuePanel.value) {
setupCurrentSongObserver();
} else {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
currentSongVisible.value = true;
}
});
onBeforeUnmount(() => {
if (currentSongObserver) {
currentSongObserver.disconnect();
currentSongObserver = null;
}
});
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10);
@ -255,26 +359,6 @@ const volumeBarBg = computed(() => {
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
.vol-slider {
-webkit-appearance: none;
appearance: none;
@ -311,4 +395,31 @@ const volumeBarBg = computed(() => {
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.queue-fade-enter-active,
.queue-fade-leave-active {
transition: opacity 0.2s ease;
}
.queue-fade-enter-from,
.queue-fade-leave-to {
opacity: 0;
}
.queue-slide-enter-active,
.queue-slide-leave-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.queue-slide-enter-from,
.queue-slide-leave-to {
transform: translateX(100%);
}
@keyframes eq-bounce-sm {
0%, 100% { height: 2px; }
50% { height: 10px; }
}
.eq-bar-sm {
animation: eq-bounce-sm 0.6s ease-in-out infinite;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<Transition name="fade">
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="handleIgnore">
<div v-if="visible" class="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="!downloading && handleIgnore()">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[440px] max-h-[80vh] flex flex-col select-auto">
<div class="p-6 pb-4">
<div class="flex items-center gap-3 mb-1">

View File

@ -1,6 +1,6 @@
import { ref, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { parseLrc, mergeTranslation, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { usePlayerStore } from '../stores/player';
export function useLyric() {
@ -8,21 +8,33 @@ export function useLyric() {
const lyrics = ref<LyricLine[]>([]);
const currentLyricIdx = ref(-1);
const showTranslation = ref(true);
const hasTranslation = ref(false);
watch(() => player.currentSong, async (song) => {
if (!song) {
lyrics.value = [];
currentLyricIdx.value = -1;
hasTranslation.value = false;
return;
}
try {
const jsonStr: string = await invoke('get_lyric', { id: song.id });
const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || '';
lyrics.value = lrc ? parseLrc(lrc) : [];
const tLrc = data?.tlyric?.lyric || '';
let parsed = lrc ? parseLrc(lrc) : [];
if (tLrc && parsed.length > 0) {
parsed = mergeTranslation(parsed, tLrc);
hasTranslation.value = parsed.some(l => l.translation);
} else {
hasTranslation.value = false;
}
lyrics.value = parsed;
currentLyricIdx.value = -1;
} catch {
lyrics.value = [];
hasTranslation.value = false;
}
}, { immediate: true });
@ -34,8 +46,15 @@ export function useLyric() {
}
});
function toggleTranslation() {
showTranslation.value = !showTranslation.value;
}
return {
lyrics,
currentLyricIdx,
hasTranslation,
showTranslation,
toggleTranslation,
};
}
}

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { ref , watch } from 'vue';
import { ref, watch, nextTick } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song';
import { useSettingsStore } from './settings';
@ -422,6 +422,7 @@ export const usePlayerStore = defineStore('player', () => {
function openRoamDrawer(tab: 'lyric' | 'comment' = 'lyric') {
roamInitialTab.value = tab;
showRoamDrawer.value = true;
nextTick(() => { roamInitialTab.value = 'lyric'; });
}
function openCommentForSong(songId: number) {

View File

@ -2,9 +2,29 @@ import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
export type ThemeMode = 'dark' | 'light';
export type ThemeName = 'green' | 'rose' | 'blue' | 'violet' | 'orange' | 'cyan' | 'pink';
export type CloseAction = 'ask' | 'minimize' | 'exit';
export const themeLabels: Record<ThemeName, string> = {
green: '翠绿',
rose: '玫红',
blue: '天蓝',
violet: '紫罗兰',
orange: '橙色',
cyan: '青色',
pink: '粉色',
};
export const themeColors: Record<ThemeName, string> = {
green: '#22c55e',
rose: '#f43f5e',
blue: '#3b82f6',
violet: '#8b5cf6',
orange: '#f97316',
cyan: '#06b6d4',
pink: '#ec4899',
};
export const qualityLabels: Record<AudioQuality, string> = {
standard: '标准',
higher: '较高',
@ -25,10 +45,12 @@ export interface ShortcutBinding {
}
export const defaultShortcuts: Record<string, ShortcutBinding> = {
playPause: { key: 'Control+KeyP', label: '播放/暂停' },
prev: { key: 'Control+ArrowLeft', label: '上一首' },
next: { key: 'Control+ArrowRight', label: '下一首' },
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
globalPlayPause: { key: 'Alt+Control+KeyP', label: '播放/暂停(全局)' },
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' },
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' },
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' },
@ -38,9 +60,10 @@ export const defaultShortcuts: Record<string, ShortcutBinding> = {
interface SettingsData {
audioQuality: AudioQuality;
downloadPath: string;
theme: ThemeMode;
theme: ThemeName;
closeAction: CloseAction;
shortcuts: Record<string, ShortcutBinding>;
outputDevice: string | null;
}
function loadSettings(): SettingsData {
@ -48,21 +71,25 @@ function loadSettings(): SettingsData {
const raw = localStorage.getItem('app_settings');
if (raw) {
const parsed = JSON.parse(raw);
const theme = parsed.theme || parsed.accentColor || 'green';
const validThemes: ThemeName[] = ['green', 'rose', 'blue', 'violet', 'orange', 'cyan', 'pink'];
return {
audioQuality: parsed.audioQuality || 'standard',
downloadPath: parsed.downloadPath || '',
theme: parsed.theme || 'dark',
theme: validThemes.includes(theme) ? theme : 'green',
closeAction: parsed.closeAction || 'ask',
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
outputDevice: parsed.outputDevice || null,
};
}
} catch {}
return {
audioQuality: 'standard',
downloadPath: '',
theme: 'dark',
theme: 'green',
closeAction: 'ask',
shortcuts: { ...defaultShortcuts },
outputDevice: null,
};
}
@ -71,9 +98,10 @@ export const useSettingsStore = defineStore('settings', () => {
const audioQuality = ref<AudioQuality>(saved.audioQuality);
const downloadPath = ref<string>(saved.downloadPath);
const theme = ref<ThemeMode>(saved.theme);
const theme = ref<ThemeName>(saved.theme);
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
const outputDevice = ref<string | null>(saved.outputDevice);
function setAudioQuality(q: AudioQuality) {
audioQuality.value = q;
@ -83,7 +111,7 @@ export const useSettingsStore = defineStore('settings', () => {
downloadPath.value = p;
}
function setTheme(t: ThemeMode) {
function setTheme(t: ThemeName) {
theme.value = t;
}
@ -99,21 +127,27 @@ export const useSettingsStore = defineStore('settings', () => {
shortcuts.value = { ...defaultShortcuts };
}
function setOutputDevice(device: string | null) {
outputDevice.value = device;
}
function resetAll() {
audioQuality.value = 'standard';
downloadPath.value = '';
theme.value = 'dark';
theme.value = 'green';
closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts };
outputDevice.value = null;
}
watch([audioQuality, downloadPath, theme, closeAction, shortcuts], () => {
watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice], () => {
const data: SettingsData = {
audioQuality: audioQuality.value,
downloadPath: downloadPath.value,
theme: theme.value,
closeAction: closeAction.value,
shortcuts: shortcuts.value,
outputDevice: outputDevice.value,
};
localStorage.setItem('app_settings', JSON.stringify(data));
}, { deep: true });
@ -124,10 +158,12 @@ export const useSettingsStore = defineStore('settings', () => {
theme,
closeAction,
shortcuts,
outputDevice,
setAudioQuality,
setDownloadPath,
setTheme,
setCloseAction,
setOutputDevice,
setShortcut,
resetShortcuts,
resetAll,

View File

@ -23,18 +23,19 @@
}
@layer base {
:root {
--c-bg: #030712;
--c-surface: #111827;
--c-subtle: rgba(255, 255, 255, 0.05);
--c-muted: rgba(255, 255, 255, 0.10);
--c-emphasis: rgba(255, 255, 255, 0.18);
:root,
[data-theme="green"] {
--c-bg: #020c06;
--c-surface: #0a1a10;
--c-subtle: rgba(34, 197, 94, 0.06);
--c-muted: rgba(34, 197, 94, 0.10);
--c-emphasis: rgba(34, 197, 94, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.10);
--c-line-2: rgba(255, 255, 255, 0.05);
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #22c55e;
--c-accent-hover: #16a34a;
--c-accent-text: #4ade80;
@ -45,26 +46,136 @@
--c-info: #3b82f6;
}
[data-theme="light"] {
--c-bg: #f3f4f6;
--c-surface: #ffffff;
--c-subtle: rgba(0, 0, 0, 0.04);
--c-muted: rgba(0, 0, 0, 0.08);
--c-emphasis: rgba(0, 0, 0, 0.12);
--c-content: #111827;
--c-content-2: #4b5563;
[data-theme="rose"] {
--c-bg: #0c0206;
--c-surface: #1a0a10;
--c-subtle: rgba(244, 63, 94, 0.06);
--c-muted: rgba(244, 63, 94, 0.10);
--c-emphasis: rgba(244, 63, 94, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #9ca3af;
--c-line: rgba(0, 0, 0, 0.10);
--c-line-2: rgba(0, 0, 0, 0.05);
--c-accent: #16a34a;
--c-accent-hover: #15803d;
--c-accent-text: #16a34a;
--c-accent-dim: rgba(22, 163, 74, 0.15);
--c-danger: #dc2626;
--c-danger-dim: rgba(220, 38, 38, 0.15);
--c-warning: #ca8a04;
--c-info: #2563eb;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #f43f5e;
--c-accent-hover: #e11d48;
--c-accent-text: #fb7185;
--c-accent-dim: rgba(244, 63, 94, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--c-info: #3b82f6;
}
[data-theme="blue"] {
--c-bg: #02060c;
--c-surface: #0a101a;
--c-subtle: rgba(59, 130, 246, 0.06);
--c-muted: rgba(59, 130, 246, 0.10);
--c-emphasis: rgba(59, 130, 246, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #3b82f6;
--c-accent-hover: #2563eb;
--c-accent-text: #60a5fa;
--c-accent-dim: rgba(59, 130, 246, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--c-info: #8b5cf6;
}
[data-theme="violet"] {
--c-bg: #06020c;
--c-surface: #120a1a;
--c-subtle: rgba(139, 92, 246, 0.06);
--c-muted: rgba(139, 92, 246, 0.10);
--c-emphasis: rgba(139, 92, 246, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #8b5cf6;
--c-accent-hover: #7c3aed;
--c-accent-text: #a78bfa;
--c-accent-dim: rgba(139, 92, 246, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--c-info: #3b82f6;
}
[data-theme="orange"] {
--c-bg: #0c0602;
--c-surface: #1a120a;
--c-subtle: rgba(249, 115, 22, 0.06);
--c-muted: rgba(249, 115, 22, 0.10);
--c-emphasis: rgba(249, 115, 22, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #f97316;
--c-accent-hover: #ea580c;
--c-accent-text: #fb923c;
--c-accent-dim: rgba(249, 115, 22, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--c-info: #3b82f6;
}
[data-theme="cyan"] {
--c-bg: #020c0c;
--c-surface: #0a1a1a;
--c-subtle: rgba(6, 182, 212, 0.06);
--c-muted: rgba(6, 182, 212, 0.10);
--c-emphasis: rgba(6, 182, 212, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #06b6d4;
--c-accent-hover: #0891b2;
--c-accent-text: #22d3ee;
--c-accent-dim: rgba(6, 182, 212, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--c-info: #3b82f6;
}
[data-theme="pink"] {
--c-bg: #0c020a;
--c-surface: #1a0a16;
--c-subtle: rgba(236, 72, 153, 0.06);
--c-muted: rgba(236, 72, 153, 0.10);
--c-emphasis: rgba(236, 72, 153, 0.18);
--c-content: #ffffff;
--c-content-2: #9ca3af;
--c-content-3: #6b7280;
--c-content-4: #4b5563;
--c-line: rgba(255, 255, 255, 0.08);
--c-line-2: rgba(255, 255, 255, 0.04);
--c-accent: #ec4899;
--c-accent-hover: #db2777;
--c-accent-text: #f472b6;
--c-accent-dim: rgba(236, 72, 153, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--c-info: #3b82f6;
}
html {
@ -78,6 +189,7 @@
@apply antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--c-bg);
color: var(--c-content);
position: fixed;
inset: 0;
overflow: hidden;

View File

@ -1,6 +1,7 @@
export interface LyricLine {
time: number; // 秒
time: number;
text: string;
translation?: string;
}
export function parseLrc(lrcStr: string): LyricLine[] {
@ -20,11 +21,37 @@ export function parseLrc(lrcStr: string): LyricLine[] {
}
}
}
// 按时长排序
result.sort((a, b) => a.time - b.time);
return result;
}
export function mergeTranslation(lyrics: LyricLine[], tLrcStr: string): LyricLine[] {
if (!tLrcStr) return lyrics;
const tLines = parseLrc(tLrcStr);
if (tLines.length === 0) return lyrics;
const tMap = new Map<number, string>();
for (const t of tLines) {
const key = Math.round(t.time * 100);
tMap.set(key, t.text);
}
return lyrics.map(line => {
const key = Math.round(line.time * 100);
const translation = tMap.get(key);
if (translation) {
return { ...line, translation };
}
for (let offset = -3; offset <= 3; offset++) {
const t = tMap.get(key + offset);
if (t) {
return { ...line, translation: t };
}
}
return line;
});
}
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
let index = -1;
for (let i = 0; i < lyrics.length; i++) {
@ -35,4 +62,4 @@ export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number):
}
}
return index;
}
}

View File

@ -1,5 +1,5 @@
<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>
@ -8,6 +8,14 @@
<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="selectedDevice" :options="deviceOptions" />
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">音质选择</p>
@ -20,23 +28,19 @@
<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>
<p class="text-sm font-medium mb-3">主题色</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(color, key) in themeColors"
:key="key"
@click="settings.setTheme(key)"
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
:class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
>
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
</button>
</div>
</div>
</section>
@ -157,15 +161,25 @@
<p class="text-xs text-content-3 leading-relaxed">
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验
</p>
<button
@click="handleCheckUpdate"
: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>
{{ updater.checking.value ? '检查中...' : '检查更新' }}
</button>
<div class="flex items-center gap-2">
<button
@click="handleCheckUpdate"
: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>
{{ 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>
</div>
</section>
@ -188,12 +202,42 @@
</div>
</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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast';
import { useUpdater } from '../composables/useUpdater';
import { invoke } from '@tauri-apps/api/core';
@ -206,13 +250,45 @@ const settings = useSettingsStore();
const { showToast } = useToast();
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 defaultDownloadPath = ref('');
onMounted(async () => {
appVersion.value = await getVersion();
try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
} catch {}
} catch { }
loadDevices();
});
const closeActionValue = computed({
@ -237,11 +313,6 @@ function clearDownloadPath() {
showToast('已重置为默认路径', 'success');
}
const themeOptions = [
{ label: '深色', value: 'dark' as const },
{ label: '浅色', value: 'light' as const },
];
async function handleCheckUpdate() {
const result = await updater.checkForUpdate(false);
if (!result) {
@ -249,6 +320,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);
function formatShortcut(key: string): string {
@ -303,6 +406,12 @@ function onRecordingKeydown(e: KeyboardEvent) {
}
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
const hasModifier = parts.includes('Control') || parts.includes('Alt') || parts.includes('Shift');
if (!hasModifier) {
showToast('快捷键必须包含 Ctrl、Alt 或 Shift', 'error');
recordingId.value = null;
return;
}
const combo = parts.join('+');
settings.setShortcut(recordingId.value, combo);
recordingId.value = null;