mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
Compare commits
6 Commits
v0.4.0
...
970fb15f5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 970fb15f5a | |||
| cf21c96eaf | |||
| 987d34f58b | |||
| baa6235c56 | |||
| 38c079ed5c | |||
| 68e3b92a6a |
@ -1,3 +1,8 @@
|
||||
## v0.4.1
|
||||
|
||||
添加音频输出外设选择
|
||||
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
71
README.md
71
README.md
@ -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
2
src-tauri/Cargo.lock
generated
@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "Nekosonic"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cpal",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "Nekosonic"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "A Simple music app"
|
||||
authors = ["atdunbg"]
|
||||
edition = "2021"
|
||||
|
||||
@ -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",
|
||||
|
||||
23
src/App.vue
23
src/App.vue
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
166
src/style.css
166
src/style.css
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user