3 Commits
v0.1.0 ... main

Author SHA1 Message Date
718d3ed641 feat: v0.3.0 - 流式播放、本地音乐、下载系统、漫游修复
### 新功能
- 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载
- 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」
- 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长)
- 封面补全:本地音乐缺少封面时自动从网易云 API 获取
- 更新信息:接入 Gitea Releases API,查看最新版更新日志

### 修复
- 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick)
- 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片
- 修复 PlayerBar 无封面歌曲显示破损图片
- 修复下载路径修改后不生效(Rust serde camelCase 映射)
- 修复本地音乐始终只显示默认路径歌曲
- 修复下载完成提示弹出 4 次
- 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步)

### 优化
- PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示
- audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended
- player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步
- 切歌/停止时立即清除 tickInterval 防止重复触发 next()
2026-05-15 02:24:48 +08:00
02f7df4201 设置应用为单例,防止多个实例同时存在 2026-05-12 06:31:37 +08:00
7847a9f6b2 feat: 跨平台持久化与版本管理优化
- Cookie 存储从 temp_dir 迁移至 Tauri app_data_dir,兼容 Linux
- 简单统一风格,UI优化
- recentLocal 播放历史持久化到 localStorage
- 添加设置界面可以修改简单的设置
2026-05-12 09:58:07 +08:00
36 changed files with 3760 additions and 695 deletions

View File

@ -1,7 +1,81 @@
# Tauri + Vue + TypeScript
# Nekosonic
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
一款轻量的跨平台的音乐播放器支持Windows/Linux系统音源主要源自的网易云音乐。
## Recommended IDE Setup
## ✨ 特性
- [VS Code](https://code.visualstudio.com/) + [Vue - Official](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
- 🔴 网易云账号登录(扫码)
- 🎵 多音质播放(标准 / 较高 / 极高 / 无损 / Hi-Res
- 📻 私人漫游,沉浸式全屏歌词体验
- ❤️ 一键喜欢 / 取消喜欢
- 📋 歌单管理,收藏 / 取消收藏歌单
- 📅 每日推荐歌曲
- 🕐 本地播放历史记录
- 🔍 关键词搜索歌曲
- 🎤 实时滚动歌词
- 🌚 Light / Dark Mode 主题切换
- 🛠 更多特性添加中
## 📦️ 安装
访问本项目的 [Releases](https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music/releases) 页面下载安装包。
## 💻 配置开发环境
```bash
# 安装前端依赖
npm install
# 启动开发服务器
npm run tauri dev
# 构建发布
npm run tauri build
```
### 环境要求
- Node.js >= 18
- Rust >= 1.70
- Tauri CLI 2
## 🛠 技术栈
| 层级 | 技术 |
|------|------|
| 桌面框架 | Tauri 2 |
| 前端 | Vue 3 + TypeScript |
| 样式 | Tailwind CSS v4 + CSS 变量主题系统 |
| 状态管理 | Pinia |
| 路由 | Vue Router 4 |
| 音频播放 | rodio (Rust) |
| 网易云 API | ncm-api-rs |
| 构建工具 | Vite 6 |
## ☑️ Todo
- [ ] MV 播放
- [ ] 音乐云盘
- [ ] 评论系统
- [ ] 下载功能
- [ ] 自定义全局快捷键
- [ ] 歌词翻译
- [ ] 更多主题
欢迎提 Issue 和 Pull request。
## 📜 开源许可
本项目仅供个人学习研究使用,禁止用于商业及非法用途。
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
## 致谢
- [ncm-api-rs](https://crates.io/crates/ncm-api-rs) — 网易云音乐 API 的 Rust 封装
- [Tauri](https://tauri.app/) — 跨平台桌面应用框架
- [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架
- [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架
- [rodio](https://crates.io/crates/rodio) — Rust 音频播放库

45
package-lock.json generated
View File

@ -1,15 +1,19 @@
{
"name": "demo",
"name": "nekosonic",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "demo",
"name": "nekosonic",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"axios": "^1.16.0",
"howler": "^2.2.4",
"pinia": "^3.0.4",
@ -1485,6 +1489,24 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
"integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
@ -1494,6 +1516,24 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@ -1507,7 +1547,6 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}

View File

@ -1,7 +1,7 @@
{
"name": "nekosonic",
"private": true,
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -11,7 +11,11 @@
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"axios": "^1.16.0",
"howler": "^2.2.4",
"pinia": "^3.0.4",

420
src-tauri/Cargo.lock generated
View File

@ -4,9 +4,13 @@ version = 4
[[package]]
name = "Nekosonic"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"base64 0.22.1",
"cpal",
"dirs 5.0.1",
"futures-util",
"lofty",
"ncm-api-rs",
"reqwest 0.12.28",
"rodio",
@ -14,7 +18,11 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tokio",
]
@ -858,6 +866,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "dbus"
version = "0.9.11"
@ -922,13 +936,34 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@ -939,7 +974,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@ -1472,6 +1507,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@ -1597,6 +1642,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@ -2309,6 +2372,32 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "log"
version = "0.4.29"
@ -2722,6 +2811,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@ -2826,6 +2916,15 @@ dependencies = [
"byteorder",
]
[[package]]
name = "ogg_pager"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@ -2914,6 +3013,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
@ -3306,7 +3411,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@ -3404,6 +3509,17 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@ -3502,12 +3618,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
@ -3542,10 +3660,34 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@ -4289,7 +4431,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@ -4339,7 +4481,7 @@ checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@ -4409,6 +4551,63 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@ -4431,6 +4630,31 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.11.0"
@ -4904,7 +5128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2",
@ -5220,6 +5444,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@ -5589,6 +5826,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -5607,6 +5853,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -5631,6 +5886,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@ -5640,13 +5910,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@ -5671,90 +5958,180 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@ -5899,7 +6276,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dom_query",
"dpi",
"dunce",
@ -5954,6 +6331,29 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.2"

View File

@ -1,6 +1,6 @@
[package]
name = "Nekosonic"
version = "0.1.0"
version = "0.3.0"
description = "A Simple music app"
authors = ["atdunbg"]
edition = "2021"
@ -20,12 +20,20 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-dialog = "2"
rodio = "0.20"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
cpal = { version = "0.15" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "stream"] }
futures-util = "0.3"
dirs = "5"
lofty = "0.22"
base64 = "0.22"
ncm-api-rs = "0.1"
tokio = { version = "1", features = ["rt", "sync"] }
tauri-plugin-process = "2.3.1"

View File

@ -10,7 +10,16 @@
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize"
"core:window:allow-toggle-maximize",
"core:window:allow-unminimize",
"core:window:allow-show",
"core:window:allow-set-focus",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"dialog:allow-open",
"process:allow-restart"
]
}

View File

@ -1 +0,0 @@
NMTID=00OvETy78e8ay9VhUTKgcUSdB6-yKQAAAGeAJacOg

View File

@ -1,22 +1,29 @@
use ncm_api_rs::{create_client, ApiClient, Query};
use serde::Deserialize;
use tauri::State;
use tokio::sync::Mutex; // 异步 Mutex
use std::sync::Mutex as StdMutex; // 同步 Mutex 用于 cookie
use serde_json::json;
use tauri::{Manager, State, Emitter};
use tokio::sync::Mutex;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::Ordering;
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::hash::{Hash, Hasher};
use lofty::file::{AudioFile, TaggedFileExt};
use lofty::tag::Accessor;
use base64::Engine;
pub struct ApiController {
client: Mutex<ApiClient>,
cookie: StdMutex<Option<String>>,
cookie_path: PathBuf,
cookie_path: PathBuf,
}
fn cookies_to_key_values(cookies: &[String]) -> String {
cookies
.iter()
.filter_map(|c| c.split(';').next()) // 取第一个键值对
.filter_map(|c| c.split(';').next())
.map(|s| s.trim().to_string())
.collect::<Vec<_>>()
.join("; ")
@ -24,14 +31,14 @@ fn cookies_to_key_values(cookies: &[String]) -> String {
impl ApiController {
pub fn new() -> Self {
let cookie_path = std::env::temp_dir().join("netease_cookies.json");
pub fn new(app_data_dir: PathBuf) -> Self {
let _ = fs::create_dir_all(&app_data_dir);
let cookie_path = app_data_dir.join("netease_cookies.json");
let saved_cookie = fs::read_to_string(&cookie_path)
.map(|s| s.trim().to_string())
.ok(); // 注意这里返回 Option<String>
// eprintln!("[api] 启动时加载 cookie: {:?}", saved_cookie);
.ok();
let client = create_client(None); // 不依赖客户端存储,我们自己管理
let client = create_client(None);
ApiController {
client: Mutex::new(client),
cookie: StdMutex::new(saved_cookie),
@ -43,13 +50,11 @@ fn build_query(&self) -> Query {
let mut query = Query::new();
if let Ok(cookie_guard) = self.cookie.lock() {
if let Some(c) = cookie_guard.as_ref() {
// eprintln!("[api] 请求携带 cookie: {}", c);
query = query.cookie(c);
}
}
query
}
/// 保存 cookie 到文件
fn save_cookie(&self, cookie_str: &str) {
let _ = fs::write(&self.cookie_path, cookie_str);
}
@ -58,14 +63,13 @@ fn build_query(&self) -> Query {
#[derive(Deserialize)]
pub struct SearchQuery { pub keyword: String }
#[derive(Deserialize)]
pub struct LoginQuery { pub phone: String, pub password: String }
#[derive(Deserialize)]
pub struct QrKeyQuery { pub key: String }
// 搜索歌曲
/// 搜索歌曲
#[tauri::command]
pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -78,7 +82,7 @@ pub async fn search_songs(query: SearchQuery, state: State<'_, ApiController>) -
.map_err(|e| e.to_string())
}
// 获取热搜词
/// 获取热搜词列表
#[tauri::command]
pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -88,23 +92,74 @@ pub async fn get_hot_search(state: State<'_, ApiController>) -> Result<String, S
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option<i64>, pub offset: Option<i64> }
// 获取歌曲链接
/// 获取歌单全部歌
#[tauri::command]
pub async fn get_song_url(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let q = state.build_query()
.param("id", &id.to_string())
.param("level", "standard");
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
resp.body["data"][0]["url"].as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| "暂无播放源".into())
.param("id", &query.id.to_string())
.param("limit", &query.limit.unwrap_or(1000).to_string())
.param("offset", &query.offset.unwrap_or(0).to_string());
client.playlist_track_all(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
// 获取歌
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
#[tauri::command]
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let level = query.level.as_deref().unwrap_or("standard");
let resp = if query.fm_mode.unwrap_or(false) {
let mut fm_cookie = state.cookie.lock().ok().and_then(|g| g.clone()).unwrap_or_default();
if !fm_cookie.contains("os=") {
fm_cookie = format!("{}; os=android; appver=8.10.05", fm_cookie);
}
let data = serde_json::json!({
"ids": format!("[{}]", query.id),
"level": level,
"encodeType": "flac",
"feeProcess": "true"
});
let option = ncm_api_rs::request::RequestOption {
crypto: ncm_api_rs::request::CryptoType::default(),
cookie: Some(fm_cookie),
ua: None,
proxy: None,
real_ip: None,
random_cn_ip: false,
e_r: None,
domain: None,
check_token: false,
};
client.request(
"/api/song/enhance/player/url/v1",
data,
option,
).await.map_err(|e| e.to_string())?
} else {
let q = state.build_query()
.param("id", &query.id.to_string())
.param("level", level);
client.song_url_v1(&q).await.map_err(|e| e.to_string())?
};
let data = &resp.body["data"][0];
let url = data["url"].as_str().filter(|s| !s.is_empty());
if url.is_none() {
return Err("暂无播放源".into());
}
Ok(data.to_string())
}
/// 获取歌词
#[tauri::command]
pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -114,8 +169,7 @@ pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result<Strin
.map_err(|e| e.to_string())
}
// 获取歌单详情
/// 获取歌单详情
#[tauri::command]
pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -125,7 +179,7 @@ pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Re
.map_err(|e| e.to_string())
}
// 登录
/// 手机号密码登录
#[tauri::command]
pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -143,17 +197,15 @@ pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result
Ok(resp.body.to_string())
}
// 登出
/// 退出登录
#[tauri::command]
pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> {
// 清除内存中的 cookie
*state.cookie.lock().map_err(|e| e.to_string())? = None;
// 删除持久化文件
let _ = fs::remove_file(&state.cookie_path);
Ok(())
}
// 获取二维码key
/// 获取二维码登录密钥
#[tauri::command]
pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -165,7 +217,7 @@ pub async fn get_qr_key(state: State<'_, ApiController>) -> Result<String, Strin
.ok_or_else(|| "缺少 unikey".into())
}
// 创建二维码, 功能暂时有问题
/// 生成二维码图片
#[tauri::command]
pub async fn create_qr(
query: QrKeyQuery,
@ -177,7 +229,6 @@ pub async fn create_qr(
.param("key", &query.key)
.param("qrimg", "true");
let resp = client.login_qr_create(&q).await.map_err(|e| e.to_string())?;
// 提取 qrurl 字段(网易云新的返回格式)
let qrurl = resp.body["data"]["qrurl"]
.as_str()
.ok_or("未获取到二维码链接")?
@ -185,7 +236,7 @@ pub async fn create_qr(
Ok(qrurl)
}
// 检查二维码状态
/// 检查二维码扫码状态
#[tauri::command]
pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -199,7 +250,7 @@ pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>)
Ok(resp.body.to_string())
}
// 获取登录状态
/// 获取当前登录状态
#[tauri::command]
pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -209,7 +260,7 @@ pub async fn get_login_status(state: State<'_, ApiController>) -> Result<String,
.map_err(|e| e.to_string())
}
// 用户歌单
/// 获取用户歌单列表
#[tauri::command]
pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -218,7 +269,7 @@ pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result<
Ok(resp.body.to_string())
}
// 每日推荐歌曲
/// 获取每日推荐歌曲
#[tauri::command]
pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -227,7 +278,7 @@ pub async fn recommend_songs(state: State<'_, ApiController>) -> Result<String,
Ok(resp.body.to_string())
}
// 推荐歌单(需要登录)
/// 获取推荐歌单
#[tauri::command]
pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -236,6 +287,7 @@ pub async fn recommend_resource(state: State<'_, ApiController>) -> Result<Strin
Ok(resp.body.to_string())
}
/// 获取私人漫游歌曲
#[tauri::command]
pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
@ -244,10 +296,435 @@ pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, Stri
Ok(resp.body.to_string())
}
/// 获取歌曲详情
#[tauri::command]
pub async fn get_song_detail(id: u64, state: State<'_, ApiController>) -> Result<String, String> {
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let q = state.build_query().param("ids", &id.to_string());
let q = state.build_query().param("ids", &id);
let resp = client.song_detail(&q).await.map_err(|e| e.to_string())?;
Ok(resp.body.to_string())
}
}
#[derive(Deserialize)]
pub struct UserRecordQuery { pub uid: u64, pub r#type: String }
#[derive(Deserialize)]
pub struct LikeSongQuery { pub id: u64, pub like: String }
/// 获取喜欢的歌曲ID列表
#[tauri::command]
pub async fn likelist(uid: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let q = state.build_query().param("uid", &uid.to_string());
client.likelist(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
}
/// 获取用户播放记录
#[tauri::command]
pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let q = state.build_query()
.param("uid", &query.uid.to_string())
.param("type", &query.r#type);
client.user_record(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
}
/// 喜欢/取消喜欢歌曲
#[tauri::command]
pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let q = state.build_query()
.param("id", &query.id.to_string())
.param("like", &query.like);
client.like(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
}
/// 上报最近播放歌曲
#[tauri::command]
pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let q = state.build_query().param("limit", &limit.to_string());
client.record_recent_song(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option<bool> }
/// 收藏/取消收藏歌单
#[tauri::command]
pub async fn playlist_subscribe(query: PlaylistSubscribeQuery, state: State<'_, ApiController>) -> Result<String, String> {
let client = state.client.lock().await;
let t = if query.subscribe.unwrap_or(true) { "1" } else { "0" };
let q = state.build_query()
.param("id", &query.id.to_string())
.param("t", t);
client.playlist_subscribe(&q).await
.map(|r| r.body.to_string())
.map_err(|e| e.to_string())
}
/// 退出应用
#[tauri::command]
pub async fn exit_app(app_handle: tauri::AppHandle) {
crate::ALLOW_EXIT.store(true, Ordering::SeqCst);
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSongInfo {
pub id: u64,
pub name: String,
pub artist: String,
pub album: String,
pub duration: u64,
pub cover: Option<String>,
pub filename: String,
pub file_size: u64,
pub path: String,
pub local: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadSongQuery {
pub id: u64,
pub name: String,
pub artist: String,
pub album: Option<String>,
pub duration: Option<u64>,
pub cover_url: Option<String>,
pub level: Option<String>,
pub download_path: Option<String>,
}
#[tauri::command]
pub async fn download_song(
app_handle: tauri::AppHandle,
query: DownloadSongQuery,
state: State<'_, ApiController>,
) -> Result<String, String> {
let level = query.level.as_deref().unwrap_or("standard");
let q = state.build_query()
.param("id", &query.id.to_string())
.param("level", level);
let client = state.client.lock().await;
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
let data = &resp.body["data"][0];
let url = data["url"].as_str().filter(|s| !s.is_empty());
if url.is_none() {
let free_trial = data.get("freeTrialInfo");
if free_trial.is_some() && !free_trial.unwrap().is_null() {
return Err("VIP歌曲无法下载".into());
}
return Err("暂无下载源,可能需要 VIP 权限".into());
}
let url = url.unwrap();
let free_trial = data.get("freeTrialInfo");
if free_trial.is_some() && !free_trial.unwrap().is_null() {
return Err("VIP歌曲无法下载".into());
}
let ext = if url.contains(".flac") { "flac" } else { "mp3" };
drop(client);
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
let _ = fs::create_dir_all(&download_dir);
let safe_name = sanitize_filename(&query.name);
let safe_artist = sanitize_filename(&query.artist);
let filename = format!("{} - {}.{}", safe_artist, safe_name, ext);
let filepath = download_dir.join(&filename);
if filepath.exists() {
return Err("文件已存在".into());
}
let resp = reqwest::get(url).await.map_err(|e| format!("下载失败: {}", e))?;
let total_size = resp.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let temp_path = filepath.with_extension(format!("{}.tmp", ext));
let mut file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?;
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
let mut chunk_count: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("读取失败: {}", e))?;
file.write_all(&chunk).map_err(|e| format!("写入失败: {}", e))?;
downloaded += chunk.len() as u64;
chunk_count += 1;
if chunk_count % 8 == 0 || downloaded == total_size {
let progress = if total_size > 0 {
(downloaded as f64 / total_size as f64) * 100.0
} else {
0.0
};
let _ = app_handle.emit("download-progress", json!({
"id": query.id,
"progress": progress,
"name": query.name,
}));
}
}
drop(file);
fs::rename(&temp_path, &filepath).map_err(|e| format!("重命名失败: {}", e))?;
let meta = json!({
"id": query.id,
"name": query.name,
"artist": query.artist,
"album": query.album,
"duration": query.duration,
"coverUrl": query.cover_url,
"filename": filename,
});
let meta_path = download_dir.join(format!("{}.json", query.id));
let mut meta_file = fs::File::create(&meta_path).map_err(|e| format!("创建元数据失败: {}", e))?;
meta_file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes())
.map_err(|e| format!("写入元数据失败: {}", e))?;
let _ = app_handle.emit("download-progress", json!({
"id": query.id,
"progress": 100.0,
"name": query.name,
}));
Ok(filename)
}
#[tauri::command]
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
if !download_dir.exists() {
return Ok(Vec::new());
}
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
let mut meta_map: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(filename) = meta["filename"].as_str() {
meta_map.insert(filename.to_string(), meta);
}
}
}
}
}
let mut songs: Vec<LocalSongInfo> = Vec::new();
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !audio_exts.contains(&ext.to_lowercase().as_str()) {
continue;
}
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
if let Some(meta) = meta_map.get(&filename) {
let meta_title = meta["name"].as_str().unwrap_or("");
let meta_artist = meta["artist"].as_str().unwrap_or("");
let meta_album = meta["album"].as_str().unwrap_or("");
let meta_duration = meta["duration"].as_u64().unwrap_or(0);
let meta_cover_url = meta["coverUrl"].as_str().unwrap_or("");
let final_title = if title.is_empty() { meta_title.to_string() } else { title };
let final_artist = if artist.is_empty() { meta_artist.to_string() } else { artist };
let final_album = if album.is_empty() { meta_album.to_string() } else { album };
let final_duration = if duration_ms == 0 { meta_duration } else { duration_ms };
let final_cover = cover_b64.or_else(|| {
if meta_cover_url.is_empty() { None } else { Some(meta_cover_url.to_string()) }
});
songs.push(LocalSongInfo {
id: meta["id"].as_u64().unwrap_or(0),
name: final_title,
artist: final_artist,
album: final_album,
duration: final_duration,
cover: final_cover,
filename,
file_size,
path: path.to_string_lossy().to_string(),
local: true,
});
} else {
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let (parsed_artist, parsed_name) = parse_filename(&stem);
let final_title = if title.is_empty() { parsed_name } else { title };
let final_artist = if artist.is_empty() { parsed_artist } else { artist };
let mut hasher = std::collections::hash_map::DefaultHasher::new();
filename.hash(&mut hasher);
let hash_id = hasher.finish();
songs.push(LocalSongInfo {
id: hash_id,
name: final_title,
artist: final_artist,
album,
duration: duration_ms,
cover: cover_b64,
filename,
file_size,
path: path.to_string_lossy().to_string(),
local: true,
});
}
}
Ok(songs)
}
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
match lofty::read_from_path(path) {
Ok(tagged_file) => {
let properties = tagged_file.properties();
let duration_ms = properties.duration().as_millis() as u64;
let tag = tagged_file.primary_tag();
let (title, artist, album) = if let Some(t) = tag {
let title = t.title().map(|s| s.to_string()).unwrap_or_default();
let artist = t.artist().map(|s| s.to_string()).unwrap_or_default();
let album = t.album().map(|s| s.to_string()).unwrap_or_default();
(title, artist, album)
} else {
(String::new(), String::new(), String::new())
};
let cover_b64 = if let Some(t) = tag {
if let Some(pic) = t.pictures().first() {
let data = pic.data();
let mime = pic.mime_type().map(|m| m.to_string()).unwrap_or_else(|| "image/jpeg".to_string());
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
Some(format!("data:{};base64,{}", mime, b64))
} else {
None
}
} else {
None
};
(title, artist, album, duration_ms, cover_b64)
}
Err(e) => {
eprintln!("[api] 读取音频元数据失败 {}: {}", path.display(), e);
(String::new(), String::new(), String::new(), 0, None)
}
}
}
fn parse_filename(stem: &str) -> (String, String) {
if let Some(pos) = stem.find(" - ") {
let artist = &stem[..pos];
let name = &stem[pos + 3..];
(artist.trim().to_string(), name.trim().to_string())
} else if let Some(pos) = stem.find('-') {
let artist = &stem[..pos];
let name = &stem[pos + 1..];
(artist.trim().to_string(), name.trim().to_string())
} else {
("".to_string(), stem.trim().to_string())
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteLocalSongQuery {
pub id: u64,
pub filename: String,
pub download_path: Option<String>,
}
#[tauri::command]
pub fn delete_local_song(
app_handle: tauri::AppHandle,
query: DeleteLocalSongQuery,
) -> Result<(), String> {
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
let file_path = download_dir.join(&query.filename);
let meta_path = download_dir.join(format!("{}.json", query.id));
if file_path.exists() {
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
}
if meta_path.exists() {
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
}
Ok(())
}
#[tauri::command]
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
let meta_path = download_dir.join(format!("{}.json", id));
Ok(meta_path.exists())
}
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
if let Some(path) = custom_path {
if !path.is_empty() {
return PathBuf::from(path);
}
}
get_default_download_dir(app_handle)
}
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
if let Ok(dir) = app_handle.path().app_data_dir() {
let download_dir = dir.join("downloads");
return download_dir;
}
let music_dir = dirs::audio_dir().unwrap_or_else(|| {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
});
music_dir.join("Nekosonic")
}
#[tauri::command]
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
get_default_download_dir(&app_handle).to_string_lossy().to_string()
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
|| c == '"' || c == '<' || c == '>' || c == '|'
{
'_'
} else {
c
}
})
.collect::<String>()
.trim()
.to_string()
}

View File

@ -1,8 +1,8 @@
use rodio::{Decoder, OutputStream, Sink, Source};
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use std::io::Cursor;
use std::io::{Read, Seek, SeekFrom};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
use tauri::AppHandle;
@ -11,6 +11,7 @@ use tauri::Emitter;
// ---------- 命令 ----------
enum AudioCmd {
Play(String),
PlayLocal(String),
Pause,
Resume,
Stop,
@ -26,21 +27,25 @@ pub struct AudioController {
impl AudioController {
pub fn new(app_handle: AppHandle) -> Self {
let (tx, rx) = channel();
let current_url = Arc::new(Mutex::new(None));
let url_clone = current_url.clone();
let ah_clone = app_handle.clone(); // 克隆一个用于闭包
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
AudioController {
tx,
current_url,
let (tx, rx) = channel();
let current_url = Arc::new(Mutex::new(None));
let url_clone = current_url.clone();
let ah_clone = app_handle.clone();
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
AudioController {
tx,
current_url,
}
}
}
pub fn play_url(&self, url: &str) {
*self.current_url.lock().unwrap() = Some(url.to_string());
let _ = self.tx.send(AudioCmd::Play(url.to_string()));
}
pub fn play_local(&self, path: &str) {
*self.current_url.lock().unwrap() = Some(path.to_string());
let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string()));
}
pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); }
pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); }
pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); }
@ -55,44 +60,179 @@ impl AudioController {
}
}
use std::io::Read;
// ---------- 流式缓冲区 ----------
fn download_audio_with_progress(
struct BufferState {
bytes: Vec<u8>,
done: bool,
cancelled: bool,
}
struct SharedBuffer {
state: Mutex<BufferState>,
available: Condvar,
}
impl SharedBuffer {
fn new() -> Self {
SharedBuffer {
state: Mutex::new(BufferState {
bytes: Vec::new(),
done: false,
cancelled: false,
}),
available: Condvar::new(),
}
}
fn write_chunk(&self, chunk: &[u8]) {
let mut state = self.state.lock().unwrap();
state.bytes.extend_from_slice(chunk);
self.available.notify_all();
}
fn mark_done(&self) {
let mut state = self.state.lock().unwrap();
state.done = true;
self.available.notify_all();
}
fn cancel(&self) {
let mut state = self.state.lock().unwrap();
state.cancelled = true;
self.available.notify_all();
}
fn len(&self) -> usize {
self.state.lock().unwrap().bytes.len()
}
fn is_done(&self) -> bool {
self.state.lock().unwrap().done
}
fn is_cancelled(&self) -> bool {
self.state.lock().unwrap().cancelled
}
}
struct StreamingReader {
buffer: Arc<SharedBuffer>,
pos: usize,
}
impl StreamingReader {
fn new(buffer: Arc<SharedBuffer>) -> Self {
StreamingReader { buffer, pos: 0 }
}
}
impl Read for StreamingReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let mut state = self.buffer.state.lock().unwrap();
loop {
let available = state.bytes.len().saturating_sub(self.pos);
if available > 0 {
let to_read = std::cmp::min(buf.len(), available);
buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]);
self.pos += to_read;
return Ok(to_read);
}
if state.done {
return Ok(0);
}
if state.cancelled {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
}
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
state = result.0;
}
}
}
impl Seek for StreamingReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
let new_pos = match pos {
SeekFrom::Start(offset) => offset as i64,
SeekFrom::Current(offset) => self.pos as i64 + offset,
SeekFrom::End(offset) => {
let mut state = self.buffer.state.lock().unwrap();
loop {
if state.done {
break state.bytes.len() as i64 + offset;
}
if state.cancelled {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
}
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
state = result.0;
}
}
};
if new_pos < 0 {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek before start"));
}
let mut state = self.buffer.state.lock().unwrap();
loop {
if new_pos as usize <= state.bytes.len() {
self.pos = new_pos as usize;
return Ok(self.pos as u64);
}
if state.done {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek past end"));
}
if state.cancelled {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
}
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
state = result.0;
}
}
}
fn download_audio_streaming(
url: &str,
buffer: &SharedBuffer,
app_handle: &AppHandle,
) -> Result<Vec<u8>, String> {
) -> Result<(), String> {
let resp = reqwest::blocking::get(url)
.map_err(|e| format!("下载失败: {}", e))?;
if !resp.status().is_success() {
return Err(format!("HTTP 错误: {}", resp.status()));
}
let total_size = resp.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut buffer = Vec::new();
let mut reader = resp;
loop {
if buffer.is_cancelled() {
return Err("下载已取消".to_string());
}
let mut chunk = [0u8; 8192];
let read_size = reader.read(&mut chunk)
.map_err(|e| format!("读取失败: {}", e))?;
if read_size == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read_size]);
buffer.write_chunk(&chunk[..read_size]);
downloaded += read_size as u64;
// 发送进度事件给前端(每 8192 字节发一次,不必太频繁)
let progress = if total_size > 0 {
(downloaded as f64 / total_size as f64) * 100.0
} else {
0.0 // 未知大小时为 0
0.0
};
let _ = app_handle.emit("cache-progress", progress);
}
// 下载完成,确保进度为 100
let _ = app_handle.emit("cache-progress", 100f64);
Ok(buffer)
Ok(())
}
const INITIAL_BUFFER_SIZE: usize = 65536;
// ---------- 音频线程 ----------
fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>, app_handle: AppHandle) {
let mut selected_device: Option<String> = None;
@ -104,47 +244,148 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
sink.set_volume(current_volume);
}
let mut current_audio_data: Option<Vec<u8>> = None; // 缓存原始音频字节
let mut current_audio_buffer: Option<Arc<SharedBuffer>> = None;
let mut audio_active = false;
let mut audio_paused = false;
let mut manual_stop = false;
loop {
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(cmd) => {
match cmd {
AudioCmd::Play(url) => {
// 停止旧播放并重建干净输出
if let Some(ref sink) = output.sink {
sink.stop();
audio_active = false;
audio_paused = false;
manual_stop = false;
if let Some(ref buf) = current_audio_buffer {
buf.cancel();
}
if let Some(ref sink) = output.sink { sink.stop(); }
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
match download_audio_with_progress(&url, &app_handle) {
Ok(bytes) => {
current_audio_data = Some(bytes.clone());
let play_res = play_bytes(&bytes, sink);
if let Err(e) = play_res {
eprintln!("[audio] 播放失败: {}", e);
let buffer = Arc::new(SharedBuffer::new());
current_audio_buffer = Some(buffer.clone());
let buffer_clone = buffer.clone();
let ah_clone = app_handle.clone();
let url_clone = url.clone();
thread::spawn(move || {
if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) {
if !buffer_clone.is_cancelled() {
eprintln!("[audio] 流式下载失败: {}", e);
}
}
Err(e) => eprintln!("[audio] 下载失败: {}", e),
buffer_clone.mark_done();
});
loop {
let len = buffer.len();
if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
if buffer.is_cancelled() || buffer.len() == 0 {
current_audio_buffer = None;
continue;
}
let reader = StreamingReader::new(buffer.clone());
match Decoder::new(reader) {
Ok(source) => {
sink.append(source);
sink.play();
audio_active = true;
let _ = app_handle.emit("audio-started", ());
}
Err(e) => {
eprintln!("[audio] 流式解码失败: {}, 等待完整下载后重试", e);
loop {
if buffer.is_done() || buffer.is_cancelled() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
if buffer.is_cancelled() || buffer.len() == 0 {
current_audio_buffer = None;
continue;
}
let buf = current_audio_buffer.as_ref().unwrap().clone();
let reader2 = StreamingReader::new(buf);
match Decoder::new(reader2) {
Ok(source) => {
sink.append(source);
sink.play();
audio_active = true;
let _ = app_handle.emit("audio-started", ());
}
Err(e2) => {
eprintln!("[audio] 完整下载后解码也失败: {}", e2);
}
}
}
}
}
}
AudioCmd::PlayLocal(path) => {
audio_active = false;
audio_paused = false;
manual_stop = false;
if let Some(ref buf) = current_audio_buffer {
buf.cancel();
}
if let Some(ref sink) = output.sink { sink.stop(); }
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
match std::fs::read(&path) {
Ok(bytes) => {
let buffer = Arc::new(SharedBuffer::new());
buffer.write_chunk(&bytes);
buffer.mark_done();
current_audio_buffer = Some(buffer.clone());
let reader = StreamingReader::new(buffer);
match Decoder::new(reader) {
Ok(source) => {
sink.append(source);
sink.play();
audio_active = true;
let _ = app_handle.emit("audio-started", ());
}
Err(e) => eprintln!("[audio] 本地播放失败: {}", e),
}
}
Err(e) => eprintln!("[audio] 读取本地文件失败: {}", e),
}
}
}
AudioCmd::Pause => {
audio_paused = true;
if let Some(ref sink) = output.sink { sink.pause(); }
}
AudioCmd::Resume => {
audio_paused = false;
if let Some(ref sink) = output.sink { sink.play(); }
}
AudioCmd::Stop => {
audio_active = false;
audio_paused = false;
manual_stop = true;
if let Some(ref buf) = current_audio_buffer {
buf.cancel();
}
if let Some(ref sink) = output.sink { sink.stop(); }
}
AudioCmd::Seek(time) => {
if let Some(ref sink) = output.sink {
// 优先尝试高效的 sink.try_seek毫秒级
let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sink.try_seek(Duration::from_secs_f64(time))
}));
@ -153,19 +394,34 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
Ok(Ok(_)) => { /* 成功 */ }
Ok(Err(e)) => {
eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e);
// 回退方案:重新解码并从目标时间开始
if let Some(ref bytes) = current_audio_data {
if let Some(ref buffer) = current_audio_buffer {
sink.stop();
sink.clear();
let _ = play_bytes_with_seek(bytes, sink, time);
let reader = StreamingReader::new(buffer.clone());
match Decoder::new(reader) {
Ok(source) => {
let source = source.skip_duration(Duration::from_secs_f64(time));
sink.append(source);
sink.play();
}
Err(e) => eprintln!("[audio] seek 解码失败: {}", e),
}
}
}
Err(_) => {
eprintln!("[audio] try_seek 崩溃,回退重建解码");
if let Some(ref bytes) = current_audio_data {
if let Some(ref buffer) = current_audio_buffer {
sink.stop();
sink.clear();
let _ = play_bytes_with_seek(bytes, sink, time);
let reader = StreamingReader::new(buffer.clone());
match Decoder::new(reader) {
Ok(source) => {
let source = source.skip_duration(Duration::from_secs_f64(time));
sink.append(source);
sink.play();
}
Err(e) => eprintln!("[audio] seek 解码失败: {}", e),
}
}
}
}
@ -184,10 +440,16 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
// 如果正在播放,恢复播放
if current_url.lock().unwrap().is_some() {
if let Some(ref bytes) = current_audio_data {
let _ = play_bytes(bytes, sink);
if let Some(ref buffer) = current_audio_buffer {
let reader = StreamingReader::new(buffer.clone());
match Decoder::new(reader) {
Ok(source) => {
sink.append(source);
sink.play();
}
Err(e) => eprintln!("[audio] 设备切换解码失败: {}", e),
}
}
}
}
@ -199,7 +461,16 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// 跟随系统默认设备变化
if audio_active && !audio_paused {
if let Some(ref sink) = output.sink {
if sink.empty() {
audio_active = false;
if !manual_stop {
let _ = app_handle.emit("audio-ended", ());
}
}
}
}
if selected_device.is_none() {
let current_default = get_system_default_device_name();
if current_default != last_default_name {
@ -208,8 +479,12 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
output = create_output(&selected_device);
if let Some(ref sink) = output.sink {
sink.set_volume(current_volume);
if let Some(ref bytes) = current_audio_data {
let _ = play_bytes(bytes, sink);
if let Some(ref buffer) = current_audio_buffer {
let reader = StreamingReader::new(buffer.clone());
let _ = Decoder::new(reader).map(|source| {
sink.append(source);
sink.play();
});
}
}
}
@ -220,28 +495,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
}
}
// ---------- 播放辅助函数 ----------
/// 直接播放字节数据
fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> {
let cursor = Cursor::new(bytes.to_vec());
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
sink.append(source);
sink.play();
Ok(())
}
/// 播放字节数据并跳过指定秒数(用于 seek 回退)
fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> {
let cursor = Cursor::new(bytes.to_vec());
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
let source = source.skip_duration(Duration::from_secs_f64(seek_secs));
sink.append(source);
sink.play();
Ok(())
}
// ---------- 其余函数保持不变(获取设备、创建输出等) ----------
// ---------- 其余函数保持不变 ----------
fn get_system_default_device_name() -> Option<String> {
rodio::cpal::default_host()
@ -340,6 +594,13 @@ pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String>
Ok(())
}
#[tauri::command]
pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> {
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
ctrl.play_local(&path);
Ok(())
}
#[tauri::command]
pub fn pause_audio(state: State<'_, AppAudio>) {
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
@ -379,4 +640,4 @@ pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
if let Ok(ctrl) = state.0.lock() {
ctrl.set_volume(vol);
}
}
}

View File

@ -1,24 +1,25 @@
use tauri::{
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
menu::{MenuBuilder, MenuItemBuilder},
Manager, LogicalSize, Emitter,
Manager, Emitter,
};
use std::sync::atomic::{AtomicBool, Ordering};
mod api;
mod audio;
use api::ApiController;
use audio::AppAudio;
static ALLOW_EXIT: AtomicBool = AtomicBool::new(false);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let window = app.get_webview_window("main").unwrap();
// 窗口最小尺寸
window.set_min_size(Some(LogicalSize::new(1280.0, 700.0)))?;
// 注入控制器
let api_controller = ApiController::new();
let app_data_dir = app.path().app_data_dir().expect("无法获取应用数据目录");
let api_controller = ApiController::new(app_data_dir);
app.manage(api_controller);
let audio_controller = audio::AudioController::new(app.handle().clone());
@ -55,6 +56,7 @@ pub fn run() {
"show" => {
window.show().unwrap();
window.set_focus().unwrap();
let _ = app.emit("window-shown", ());
}
"play_pause" => {
let _ = app.emit("tray-play-pause", ());
@ -66,7 +68,10 @@ pub fn run() {
let _ = app.emit("tray-prev", ());
}
"quit" => {
app.exit(0);
ALLOW_EXIT.store(true, Ordering::SeqCst);
if let Some(w) = app.get_webview_window("main") {
let _ = w.close();
}
}
_ => {}
}
@ -82,16 +87,23 @@ pub fn run() {
let window = app.get_webview_window("main").unwrap();
window.show().unwrap();
window.set_focus().unwrap();
let _ = app.emit("window-shown", ());
}
})
.build(app)?;
// 点击关闭按钮时隐藏到托盘
let window = app.get_webview_window("main").unwrap();
let window_clone = window.clone();
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event {
close_api.prevent_close(); // 阻止窗口关闭
let _ = window_clone.hide(); // 隐藏到托盘
if ALLOW_EXIT.load(Ordering::SeqCst) {
return;
}
close_api.prevent_close();
let _ = window_clone.hide();
let _ = app_handle.emit("window-hidden", ());
}
});
@ -115,16 +127,42 @@ pub fn run() {
api::create_qr,
api::check_qr_status,
api::get_login_status,
api::likelist,
api::user_record,
api::like_song,
api::record_recent_song,
api::playlist_subscribe,
api::playlist_track_all,
api::exit_app,
audio::play_audio,
audio::play_local_audio,
audio::pause_audio,
audio::resume_audio,
audio::stop_audio,
audio::get_output_devices,
audio::set_output_device,
audio::seek_audio,
audio::set_volume
audio::set_volume,
api::download_song,
api::list_local_songs,
api::delete_local_song,
api::check_local_song,
api::get_default_download_path
])
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
let _ = app.emit("window-shown", ());
}
}))
.run(tauri::generate_context!())
.expect("error while running Nekosonic");
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic",
"version": "0.1.0",
"version": "0.3.0",
"identifier": "com.atdunbg.Nekosonic",
"build": {
"beforeDevCommand": "npm run dev",
@ -13,10 +13,10 @@
"windows": [
{
"title": "Nekosonic",
"width": 1200,
"height": 700,
"minWidth": 1200,
"minHeight": 700,
"width": 1100,
"height": 680,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"decorations": false
}
@ -34,6 +34,11 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
}
}
}

View File

@ -1,112 +1,127 @@
<template>
<div class="flex flex-col h-screen bg-gray-950 text-white overflow-hidden">
<!-- ========= 自定义标题栏可拖拽无边框 ========= -->
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 bg-gray-900/90 backdrop-blur select-none flex-shrink-0"
class="h-10 flex items-center justify-between px-4 bg-surface/90 backdrop-blur select-none flex-shrink-0"
>
<span class="text-xs text-gray-400 font-medium ml-2">Nekosonic Music</span>
<span class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
<div class="flex items-center gap-1.5">
<!-- 最小化 -->
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<!-- 最大化 / 还原 -->
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<!-- 关闭 -->
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
</div>
<!-- 主体内容区 -->
<div class="flex flex-1 overflow-hidden">
<!-- 左侧导航无边框 -->
<nav class="w-56 flex-shrink-0 flex flex-col bg-gray-900/80 backdrop-blur">
<div class="flex-1 p-4 overflow-y-auto pb-24 flex flex-col">
<!-- 推荐 & 发现 -->
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
<div class="flex-1 p-4 overflow-y-auto min-h-0">
<div class="flex flex-col min-h-full">
<div class="relative mb-4">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-content-3" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input v-model="searchQuery" @keydown.enter="doSearch" type="text" placeholder="搜索音乐..."
class="w-full rounded-lg bg-subtle pl-9 pr-3 py-2 text-sm text-content placeholder-content-3 outline-none focus:bg-muted transition" />
</div>
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🏠</span> 推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🔍</span> 发现
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-9 9 9"/><path d="M5 10v10a1 1 0 001 1h3v-6h6v6h3a1 1 0 001-1V10"/></svg>
推荐
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5 w-full text-left"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
>
<span>🌀</span> 漫游
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
漫游
</button>
</div>
<!-- 我的 -->
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-gray-500 px-3 mb-1">我的</p>
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span></span> 我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span>🕐</span> 最近播放
</router-link>
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
<div class="space-y-0.5">
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<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"/><path d="M12 6v6l4 2"/></svg>
最近播放
</router-link>
<router-link to="/local-music"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
本地音乐
</router-link>
</div>
</div>
<!-- 创建的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-gray-500">我的歌单</p>
<span class="text-xs text-gray-500 transition-transform"
<p class="text-xs text-content-3">我的歌单</p>
<span class="text-xs text-content-3 transition-transform"
:class="{ 'rotate-90': showCreatedPlaylists }"></span>
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
{{ pl.name }}
</div>
</div>
</div>
<!-- 收藏的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-gray-500">收藏的歌单</p>
<span class="text-xs text-gray-500 transition-transform" :class="{ 'rotate-90': showSubPlaylists }"></span>
<p class="text-xs text-content-3">收藏的歌单</p>
<span class="text-xs text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }"></span>
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
class="px-3 py-1.5 rounded-lg text-sm cursor-pointer truncate transition-all duration-200"
:class="isPlaylistActive(pl.id) ? 'text-content bg-muted' : 'text-content-2 hover:text-content hover:bg-subtle'">
{{ pl.name }}
</div>
</div>
</div>
<!-- 用户区域 -->
<div class="mt-auto pt-4">
<div v-if="!userStore.isLoggedIn" class="px-2 space-y-2">
<p class="text-xs text-gray-500">登录后享受个人歌单</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 transition text-sm font-medium text-green-400">
<span>🔑</span> 立即登录
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
<div class="px-1">
<router-link to="/settings"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
active-class="!text-content !bg-muted">
<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="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
设置
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-green-400/50" />
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
<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="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2 mt-3">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout()"
class="text-xs text-gray-500 hover:text-red-400 transition">退出登录</button>
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
</div>
</div>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }">
<keep-alive :max="3" include="HomeView,DiscoverView">
@ -116,86 +131,222 @@
</main>
</div>
<!-- 全屏漫游抽屉 -->
<Transition name="drawer">
<div
v-if="player.showRoamDrawer"
v-if="windowVisible && player.showRoamDrawer"
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
>
<div class="h-16 flex items-center px-6 flex-shrink-0">
<button @click="player.closeRoamDrawer()" class="text-white/80 hover:text-white transition">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0" data-tauri-drag-region>
<button @click="player.closeRoamDrawer()" class="text-content-2 hover:text-content transition">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
</div>
<div class="flex-1 min-h-0 flex px-8 pb-8">
<div class="flex-shrink-0 mr-12 flex flex-col items-center self-center">
<img
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
/>
<h1 class="text-2xl font-bold text-white">{{ roamSong?.name }}</h1>
<p class="text-gray-400 mt-2">{{ roamArtists }}</p>
<div class="flex items-center gap-1.5">
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
<div 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 space-y-3 py-8">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="idx === currentLyricIdx ? 'text-green-400 font-medium text-lg transition' : 'text-gray-400 text-base'"
>
{{ line.text }}
</p>
</div>
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0">
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
<img
v-if="roamCoverUrl && !roamCoverError"
:src="roamCoverUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
@error="roamCoverError = true"
/>
<div
v-else
class="w-72 h-72 rounded-3xl bg-white/10 flex items-center justify-center shadow-2xl mb-4"
>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-white/30"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<h1 class="text-2xl font-bold text-white text-center">{{ roamSong?.name }}</h1>
<p class="text-content-2 mt-2 text-center">{{ roamArtists }}</p>
</div>
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
<div ref="lyricScrollContainer" class="h-full overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="getRoamLyricClass(idx)"
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-all duration-300"
@click="seekToRoamLyric(line.time)"
@mouseenter="roamLyricHovering = true"
@mouseleave="roamLyricHovering = false"
>
{{ line.text }}
</p>
</div>
<div v-else class="text-content-3 text-center mt-8">暂无歌词</div>
</div>
<div v-else class="text-gray-500 text-center mt-8">暂无歌词</div>
</div>
</div>
</div>
</Transition>
<!-- 底部播放栏 -->
<PlayerBar />
<PlayerBar v-if="player.currentSong" />
<ToastContainer />
<Transition name="fade">
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
<p class="text-sm text-content-2 mb-5">你希望如何处理</p>
<div class="space-y-2.5 mb-4">
<button @click="handleCloseAction('minimize')"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
</div>
<div>
<p class="text-sm font-medium text-content">最小化到托盘</p>
<p class="text-xs text-content-3">程序继续在后台运行</p>
</div>
</button>
<button @click="handleCloseAction('exit')"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-danger"><path d="M18 6L6 18M6 6l12 12"/></svg>
</div>
<div>
<p class="text-sm font-medium text-content">退出程序</p>
<p class="text-xs text-content-3">完全关闭应用程序</p>
</div>
</button>
</div>
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
<input type="checkbox" v-model="closeDontAskAgain" />
<span class="text-xs text-content-2">不再询问记住我的选择</span>
</label>
<button @click="showCloseModal = false"
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from './stores/user';
import { useSettingsStore, type CloseAction } from './stores/settings';
import PlayerBar from './components/PlayerBar.vue';
import ToastContainer from './components/ToastContainer.vue';
import { usePlayerStore } from './stores/player';
import { useLyric } from './composables/UserLyric';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen } from '@tauri-apps/api/event';
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const player = usePlayerStore();
const settings = useSettingsStore();
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
const searchQuery = ref('');
const showCloseModal = ref(false);
const closeDontAskAgain = ref(false);
const windowVisible = ref(true);
watch(() => settings.theme, (val) => {
document.documentElement.setAttribute('data-theme', val);
}, { immediate: true });
function doSearch() {
const q = searchQuery.value.trim();
if (q) router.push({ path: '/discover', query: { q } });
}
// 歌词
const { lyrics, currentLyricIdx } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamLyricHovering = ref(false);
const roamLyricPadPx = ref(0);
const roamSong = computed(() => player.currentSong);
const roamCoverError = ref(false);
const roamCoverUrl = computed(() => {
if (!roamSong.value) return '';
return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || '';
});
watch(roamCoverUrl, () => { roamCoverError.value = false; });
let roamResizeObserver: ResizeObserver | null = null;
function updateRoamLyricPad() {
if (lyricScrollContainer.value) {
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
}
}
watch(() => player.showRoamDrawer, (val) => {
if (val) {
nextTick(() => {
updateRoamLyricPad();
if (roamResizeObserver) roamResizeObserver.disconnect();
if (lyricScrollContainer.value) {
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
roamResizeObserver.observe(lyricScrollContainer.value);
}
scrollToRoamActiveLyric();
});
} else {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
}
});
onBeforeUnmount(() => {
if (roamResizeObserver) {
roamResizeObserver.disconnect();
roamResizeObserver = null;
}
});
const roamArtists = computed(() => {
if (!roamSong.value) return '';
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && lyricScrollContainer.value) {
nextTick(() => {
const active = lyricScrollContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
function scrollToRoamActiveLyric() {
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
if (active) {
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function getRoamLyricClass(idx: number): string {
const diff = Math.abs(idx - currentLyricIdx.value);
if (idx === currentLyricIdx.value) {
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';
}
function seekToRoamLyric(time: number) {
if (time != null && player.duration > 0) {
player.seek(time);
}
}
async function openRoamFromSidebar() {
if (player.isFmMode) {
player.openRoamDrawer();
@ -209,7 +360,7 @@ async function loadPlaylists() {
try {
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch (e) { /* 忽略 */ }
}
@ -218,12 +369,22 @@ function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function isPlaylistActive(id: number): boolean {
return route.name === 'playlist' && Number(route.params.id) === id;
}
watch(() => userStore.isLoggedIn, (val) => {
if (val) loadPlaylists();
if (val) {
loadPlaylists();
player.loadLikedIds();
}
});
onMounted(async () => {
if (userStore.isLoggedIn) loadPlaylists();
if (userStore.isLoggedIn) {
loadPlaylists();
player.loadLikedIds();
}
try { await invoke('stop_audio'); } catch {}
try {
const jsonStr: string = await invoke('get_login_status');
@ -239,21 +400,37 @@ onMounted(async () => {
} catch {}
});
// ---------- 窗口控制 ----------
const currentWindow = getCurrentWindow();
function minimizeWindow() { currentWindow.minimize(); }
async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
}
function closeWindow() { currentWindow.close(); }
import { listen } from '@tauri-apps/api/event';
function closeWindow() {
if (settings.closeAction === 'ask') {
closeDontAskAgain.value = false;
showCloseModal.value = true;
} else if (settings.closeAction === 'minimize') {
currentWindow.hide();
} else {
invoke('exit_app');
}
}
function handleCloseAction(action: CloseAction) {
if (closeDontAskAgain.value) {
settings.setCloseAction(action);
}
showCloseModal.value = false;
if (action === 'minimize') {
currentWindow.hide();
} else {
invoke('exit_app');
}
}
onMounted(() => {
const unlisten1 = listen('tray-play-pause', () => {
player.toggle(); // 假设 player 是 usePlayerStore 的实例
player.toggle();
});
const unlisten2 = listen('tray-next', () => {
player.next();
@ -261,15 +438,93 @@ onMounted(() => {
const unlisten3 = listen('tray-prev', () => {
player.prev();
});
const unlisten4 = listen('window-hidden', () => {
windowVisible.value = false;
});
const unlisten5 = listen('window-shown', () => {
windowVisible.value = true;
});
// 在组件卸载时取消监听
onBeforeUnmount(() => {
onBeforeUnmount(() => {
unlisten1.then(fn => fn());
unlisten2.then(fn => fn());
unlisten3.then(fn => fn());
unlisten4.then(fn => fn());
unlisten5.then(fn => fn());
});
});
async function registerGlobalShortcuts() {
const globalActions: Record<string, () => void> = {
globalPrev: () => player.prev(),
globalNext: () => player.next(),
globalVolUp: () => player.adjustVolume(5),
globalVolDown: () => player.adjustVolume(-5),
};
for (const [id, action] of Object.entries(globalActions)) {
const key = settings.shortcuts[id]?.key;
if (!key) continue;
try { await unregister(key); } catch {}
try {
await register(key, (event) => {
if (event.state === 'Pressed') action();
});
} catch {}
}
}
watch(() => settings.shortcuts, () => {
registerGlobalShortcuts();
}, { deep: true });
onMounted(() => {
registerGlobalShortcuts();
});
function parseShortcutKey(combo: string): { ctrl: boolean; alt: boolean; shift: boolean; code: string } {
const parts = combo.split('+');
return {
ctrl: parts.includes('Control'),
alt: parts.includes('Alt'),
shift: parts.includes('Shift'),
code: parts.find(p => !['Control', 'Alt', 'Shift'].includes(p)) || '',
};
}
onMounted(() => {
function onKeydown(e: KeyboardEvent) {
const el = e.target as HTMLElement;
const isEditable = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable;
if (e.code === 'Space' && !isEditable) {
e.preventDefault();
player.toggle();
}
const localActions: Record<string, () => void> = {
prev: () => player.prev(),
next: () => player.next(),
volUp: () => player.adjustVolume(5),
volDown: () => player.adjustVolume(-5),
};
for (const [id, action] of Object.entries(localActions)) {
const key = settings.shortcuts[id]?.key;
if (!key) continue;
const parsed = parseShortcutKey(key);
const ctrlMatch = parsed.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
const altMatch = parsed.alt ? e.altKey : !e.altKey;
const shiftMatch = parsed.shift ? e.shiftKey : !e.shiftKey;
if (ctrlMatch && altMatch && shiftMatch && e.code === parsed.code) {
e.preventDefault();
action();
return;
}
}
}
window.addEventListener('keydown', onKeydown);
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
});
});
</script>
<style>
@ -277,9 +532,15 @@ onBeforeUnmount(() => {
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.custom-scroll::-webkit-scrollbar { width: 4px; }
.custom-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
.roam-lyric-line:hover {
background: var(--c-subtle);
}
</style>
.roam-lyric-active:hover {
background: var(--c-subtle) !important;
}
</style>

BIN
src/assets/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,79 @@
<template>
<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="{ 'border-accent shadow-[0_0_0_2px_var(--c-accent-dim)]': isOpen }"
>
<span>{{ 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">
<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="modelValue === key ? 'bg-accent-dim text-accent-text' : 'text-content-2 hover:bg-subtle hover:text-content'"
>
<span>{{ 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>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps<{
modelValue: string;
options: Record<string, string>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const isOpen = ref(false);
const container = ref<HTMLElement | null>(null);
const currentLabel = computed(() => props.options[props.modelValue] || '');
function toggle() {
isOpen.value = !isOpen.value;
}
function select(key: string) {
emit('update:modelValue', key);
isOpen.value = false;
}
function onClickOutside(e: MouseEvent) {
if (container.value && !container.value.contains(e.target as Node)) {
isOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside);
});
</script>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@ -1,52 +1,51 @@
<template>
<div v-if="player.currentSong"
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
<!-- 歌词精简条仅在非漫游全屏时显示 -->
<div v-if="currentLyricText && !player.showRoamDrawer" @click="showFullLyric = !showFullLyric"
class="px-6 py-1 text-center text-xs text-green-200/80 cursor-pointer hover:bg-white/5 transition truncate">
{{ currentLyricText }}
</div>
<div
class="fixed bottom-0 left-0 right-0 bg-surface/95 backdrop-blur border-t border-line z-50 select-none">
<!-- 进度条 -->
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
<div ref="progressBar" class="w-full h-1.5 bg-muted rounded-full relative group cursor-pointer overflow-visible"
@mousedown.prevent="startSeek">
<!-- 缓存进度灰白 -->
<div class="absolute left-0 top-0 h-full bg-white/20 rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<!-- 播放进度绿色渐变 -->
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"
<div class="absolute left-0 top-0 h-full bg-emphasis rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<div class="absolute left-0 top-0 h-full bg-accent rounded-full"
:style="{ width: displayProgress + '%' }"></div>
<!-- 拖动圆点基于容器定位left 百分比 -->
<div
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
</div>
<!-- 主控制区 -->
<div class="flex items-center px-6 h-16">
<!-- 左侧歌曲信息 -->
<div class="flex items-center gap-3 w-56 min-w-0">
<img :src="player.currentSong.al?.picUrl"
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
@click="player.openRoamDrawer()" title="全屏展示" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
<div v-if="player.currentSong?.al?.picUrl" class="w-10 h-10 rounded-md overflow-hidden flex-shrink-0 cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
<img :src="player.currentSong.al.picUrl" class="w-full h-full object-cover" />
</div>
<div v-else class="w-10 h-10 rounded-md flex-shrink-0 bg-muted flex items-center justify-center cursor-pointer hover:scale-105 transition-transform" @click="player.toggleRoamDrawer()" title="全屏展示">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ player.currentSong?.name }}</p>
<p class="text-xs text-content-2 truncate">
{{player.currentSong?.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
<button @click="player.currentSong && player.toggleLike(player.currentSong.id)" class="flex-shrink-0 transition" :class="player.currentSong && player.isLiked(player.currentSong.id) ? 'text-danger' : 'text-content-3 hover:text-danger'">
<svg v-if="player.currentSong && player.isLiked(player.currentSong.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button v-if="player.currentSong && !download.isDownloaded(player.currentSong!.id) && !download.isDownloading(player.currentSong!.id)" @click="download.downloadSong(player.currentSong)" class="flex-shrink-0 text-content-3 hover:text-accent-text transition" title="下载">
<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="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<svg v-if="player.currentSong && download.isDownloading(player.currentSong!.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flex-shrink-0 animate-spin text-content-3"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
</div>
<!-- 中间控制按钮 + 时间 -->
<div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
'text-xl transition',
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
'transition',
player.isFmMode ? 'text-content-4 cursor-not-allowed' : 'text-content-2 hover:text-content',
]">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" y1="19" x2="5" y2="5"/></svg>
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20">
class="w-9 h-9 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis">
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
@ -56,112 +55,94 @@
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition"></button>
<button @click="player.next()" class="text-content-2 hover:text-content transition">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</button>
</div>
<div class="flex items-center gap-2 text-xs text-gray-400">
<div class="flex items-center gap-2 text-xs text-content-2">
<span>{{ formatTime(player.currentTime) }}</span>
<span>/</span>
<span>{{ formatTime(player.duration) }}</span>
</div>
</div>
<!-- 右侧音量模式播放列表 -->
<div class="w-56 flex justify-end items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-sm text-gray-400">🔊</span>
<div class="relative w-24 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
<button @click="toggleMute" class="text-content-2 hover:text-content transition">
<svg v-if="player.volume === 0" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
</button>
<div class="relative w-20 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="player.volume"
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-emphasis outline-none" />
</div>
</div>
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
{{ modeIcon }}
<button @click="togglePlayMode" class="text-content-2 hover:text-content transition" :title="modeTitle">
<svg v-if="player.playMode === 'loop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
<svg v-else-if="player.playMode === 'shuffle'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="11" y="15" font-size="8" fill="currentColor" stroke="none" font-weight="bold">1</text></svg>
</button>
<button @click="showQueuePanel = !showQueuePanel"
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
📋
class="text-content-2 hover:text-content transition relative" title="播放列表">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<span v-if="player.queue.length > 0"
class="absolute -top-1 -right-1 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
class="absolute -top-1 -right-1 bg-accent text-content text-xs rounded-full w-4 h-4 flex items-center justify-center">
{{ player.queue.length }}
</span>
</button>
</div>
</div>
<!-- 队列面板 -->
<Transition name="slide-up">
<div v-if="showQueuePanel"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
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-red-400 hover:text-red-300 transition">清空</button>
<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-green-500/20 text-white' : 'hover:bg-white/5 text-gray-300',
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-gray-500 truncate">
<p class="text-xs text-content-3 truncate">
{{song.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="text-gray-500 hover:text-red-400 transition text-sm">
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>
</button>
</div>
</div>
</div>
</Transition>
<!-- 全屏歌词浮层 -->
<Transition name="slide-up">
<div v-if="showFullLyric && lyrics.length > 0 && !player.showRoamDrawer"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-72 overflow-hidden flex flex-col"
@click.self="showFullLyric = false">
<div class="flex justify-between mb-2">
<h3 class="text-xs font-semibold">歌词</h3>
<button @click="showFullLyric = false" class="text-gray-400 hover:text-white">收起</button>
</div>
<div ref="lyricContainer"
class="flex-1 overflow-y-auto overflow-x-hidden whitespace-normal break-words space-y-1 text-sm text-center">
<p v-for="(line, idx) in lyrics" :key="idx" :class="idx === currentLyricIdx
? 'text-green-400 font-medium scale-105 transition'
: 'text-gray-400'
" class="px-4 py-0.5">
{{ line.text }}
</p>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
import { ref, computed, onBeforeUnmount, onMounted } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatTime } from '../utils/format';
import { invoke } from '@tauri-apps/api/core';
import { useLyric } from '../composables/UserLyric';
import { listen } from '@tauri-apps/api/event';
const player = usePlayerStore();
const download = useDownload();
const showQueuePanel = ref(false);
const { lyrics, currentLyricIdx, currentLyricText } = useLyric();
const showFullLyric = ref(false);
const lyricContainer = ref<HTMLElement | null>(null);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
const cacheProgress = ref(0);
const volume = ref(100);
const prevVolume = ref(100);
let unlistenCache: (() => void) | null = null;
// 缓存进度监听
onMounted(async () => {
const fn = await listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
@ -172,10 +153,7 @@ onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
// 播放模式
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
@ -183,7 +161,16 @@ function togglePlayMode() {
player.setPlayMode(next);
}
// 进度条拖拽逻辑
function toggleMute() {
if (player.volume > 0) {
prevVolume.value = player.volume;
player.volume = 0;
} else {
player.volume = prevVolume.value || 100;
}
invoke('set_volume', { vol: player.volume / 100 });
}
let onDocMove: ((e: MouseEvent) => void) | null = null;
let onDocUp: (() => void) | null = null;
@ -234,13 +221,6 @@ const displayProgress = computed(() => {
return isSeeking.value ? previewPercent.value : progressPercent.value;
});
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function playFromQueue(index: number) {
player.currentIndex = index;
player.playCurrent();
@ -249,31 +229,26 @@ function playFromQueue(index: number) {
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10);
volume.value = val;
player.volume = val;
await invoke('set_volume', { vol: val / 100 });
}
const volumeBarBg = computed(() => {
const pct = volume.value;
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
const pct = player.volume;
return `linear-gradient(to right, var(--c-accent) 0%, var(--c-accent) ${pct}%, var(--c-muted) ${pct}%)`;
});
// 歌词浮层自动滚动
watch(
() => currentLyricIdx.value,
() => {
if (showFullLyric.value && lyricContainer.value) {
nextTick(() => {
const active = lyricContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
);
</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;
@ -300,9 +275,9 @@ watch(
height: 6px;
border-radius: 3px;
background: linear-gradient(to right,
#34d399 0%,
#10b981 var(--vol-fill),
rgba(255, 255, 255, 0.15) var(--vol-fill));
var(--c-accent) 0%,
var(--c-accent) var(--vol-fill),
var(--c-muted) var(--vol-fill));
}
.vol-slider::-webkit-slider-thumb {
@ -321,4 +296,4 @@ watch(
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
</style>
</style>

View File

@ -0,0 +1,60 @@
<template>
<TransitionGroup name="toast" tag="div" class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<div
v-for="toast in toasts"
:key="toast.id"
@click="dismiss(toast.id)"
class="pointer-events-auto min-w-[280px] max-w-[400px] px-4 py-3 rounded-lg shadow-lg bg-surface/95 backdrop-blur cursor-pointer border-l-4"
:class="borderClass(toast.type)"
>
<p class="text-sm font-medium" :class="textClass(toast.type)">{{ toast.message }}</p>
</div>
</TransitionGroup>
</template>
<script setup lang="ts">
import { useToast, type Toast } from '../composables/useToast';
const { toasts } = useToast();
function borderClass(type: Toast['type']) {
return {
success: 'border-accent',
error: 'border-danger',
info: 'border-info',
}[type];
}
function textClass(type: Toast['type']) {
return {
success: 'text-accent-text',
error: 'text-danger',
info: 'text-info',
}[type];
}
function dismiss(id: number) {
const idx = toasts.value.findIndex(t => t.id === id);
if (idx !== -1) toasts.value.splice(idx, 1);
}
</script>
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
}
.toast-leave-active {
transition: all 0.2s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>

View File

@ -1,4 +1,4 @@
import { ref, computed, watch } from 'vue';
import { ref, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { usePlayerStore } from '../stores/player';
@ -9,12 +9,6 @@ export function useLyric() {
const lyrics = ref<LyricLine[]>([]);
const currentLyricIdx = ref(-1);
const currentLyricText = computed(() => {
if (lyrics.value.length === 0) return '';
const idx = currentLyricIdx.value;
return idx >= 0 && idx < lyrics.value.length ? lyrics.value[idx].text : '';
});
watch(() => player.currentSong, async (song) => {
if (!song) {
lyrics.value = [];
@ -43,6 +37,5 @@ export function useLyric() {
return {
lyrics,
currentLyricIdx,
currentLyricText,
};
}

View File

@ -0,0 +1,135 @@
import { reactive, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
interface DownloadTask {
id: number;
name: string;
progress: number;
}
const downloadingIds = reactive<Set<number>>(new Set());
const tasks = reactive<DownloadTask[]>([]);
const localSongIds = reactive<Set<number>>(new Set());
let listenerSetup = false;
let storeSetup = false;
async function setupDownloadListener() {
if (listenerSetup) return;
listenerSetup = true;
await listen<{ id: number; progress: number; name: string }>('download-progress', (event) => {
const { id, progress, name } = event.payload;
if (progress >= 100) {
const idx = tasks.findIndex(t => t.id === id);
if (idx >= 0) {
tasks.splice(idx, 1);
downloadingIds.delete(id);
showToast(`${name} 下载完成`, 'success');
}
} else {
const task = tasks.find(t => t.id === id);
if (task) {
task.progress = progress;
}
}
});
}
async function refreshLocalIds() {
try {
const settings = useSettingsStore();
const list: { id: number }[] = await invoke('list_local_songs', { downloadPath: settings.downloadPath || null });
localSongIds.clear();
for (const s of list) {
localSongIds.add(s.id);
}
} catch {}
}
function ensureStoreSetup() {
if (storeSetup) return;
storeSetup = true;
const settings = useSettingsStore();
refreshLocalIds();
watch(() => settings.downloadPath, () => {
refreshLocalIds();
});
}
function isDownloaded(songId: number): boolean {
return localSongIds.has(songId);
}
function isDownloading(songId: number): boolean {
return downloadingIds.has(songId);
}
function getDownloadProgress(songId: number): number {
const task = tasks.find(t => t.id === songId);
return task?.progress ?? 0;
}
async function downloadSong(song: { id: number; name: string; ar?: { name: string }[]; artists?: { name: string }[]; al?: { picUrl?: string; name?: string }; album?: { picUrl?: string; name?: string }; dt?: number; duration?: number }) {
if (downloadingIds.has(song.id)) return;
if (localSongIds.has(song.id)) {
showToast(`${song.name} 已下载`, 'info');
return;
}
const settings = useSettingsStore();
const artist = song.ar?.map(a => a.name).join(' / ') || song.artists?.map(a => a.name).join(' / ') || '未知';
const albumName = song.al?.name || song.album?.name || null;
const durationVal = song.dt || song.duration || null;
const coverUrl = song.al?.picUrl || song.album?.picUrl || null;
downloadingIds.add(song.id);
tasks.push({ id: song.id, name: song.name, progress: 0 });
try {
await invoke('download_song', {
query: {
id: song.id,
name: song.name,
artist,
album: albumName,
duration: durationVal,
coverUrl,
level: settings.audioQuality,
downloadPath: settings.downloadPath || null,
},
});
localSongIds.add(song.id);
} catch (e: any) {
downloadingIds.delete(song.id);
const idx = tasks.findIndex(t => t.id === song.id);
if (idx >= 0) tasks.splice(idx, 1);
if (e === '文件已存在') {
localSongIds.add(song.id);
showToast(`${song.name} 已下载`, 'info');
} else if (e === 'VIP歌曲无法下载') {
showToast(`${song.name} 为 VIP 歌曲,无法下载`, 'error');
} else if (typeof e === 'string' && e.includes('VIP')) {
showToast(`${song.name} 需要 VIP 权限才能下载`, 'error');
} else {
showToast(`下载失败: ${e}`, 'error');
}
}
}
export function useDownload() {
setupDownloadListener();
ensureStoreSetup();
return {
downloadingIds,
tasks,
localSongIds,
isDownloaded,
isDownloading,
getDownloadProgress,
downloadSong,
refreshLocalIds,
};
}

View File

@ -0,0 +1,22 @@
import { ref } from 'vue';
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
const toasts = ref<Toast[]>([]);
let nextId = 0;
export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) {
const id = nextId++;
toasts.value.push({ id, message, type });
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id);
}, duration);
}
export function useToast() {
return { toasts, showToast };
}

View File

@ -6,18 +6,22 @@ import Login from '@/views/Login.vue';
import FavoriteSongs from '@/views/FavoriteSongs.vue';
import RecentPlays from '@/views/RecentPlays.vue';
import DailySongs from '@/views/DailySongs.vue';
import LocalMusic from '@/views/LocalMusic.vue';
import Settings from '@/views/Settings.vue';
const routes = [
{ path: '/', name: 'home', component: Home },
{ path: '/discover', name: 'discover', component: Discover },
{ path: '/search', name: 'search', component: Discover }, // 同样指向Discover保留兼容
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, // 漫游页面
{ path: '/search', name: 'search', component: Discover },
{ path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') },
{ path: '/favorites', name: 'favorites', component: FavoriteSongs },
{ path: '/recent', name: 'recent', component: RecentPlays },
{ path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐
{ path: '/daily', name: 'daily', component: DailySongs },
{ path: '/local-music', name: 'local-music', component: LocalMusic },
{ path: '/login', name: 'login', component: Login },
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
{ path: '/settings', name: 'settings', component: Settings },
];
export default createRouter({

View File

@ -2,26 +2,27 @@ import { defineStore } from 'pinia';
import { ref , watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song';
import { useSettingsStore } from './settings';
import { useUserStore } from './user';
import { showToast } from '../composables/useToast';
// 设置播放模式,目前只有顺序循环,后续可扩展
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
export interface Song {
id: number;
name: string;
ar: { name: string }[];
al: { picUrl: string };
al: { picUrl: string; name?: string };
dt?: number;
// 兼容不同接口返回的可选字段
album?: { picUrl?: string };
album?: { picUrl?: string; name?: string };
artists?: { name: string }[];
duration?: number; // 某些接口的时长字段(单位可能是秒)
duration?: number;
localPath?: string;
}
const cacheProgress = ref(0);
// 监听 Tauri 事件(需要在适当位置初始化一次)
import { listen } from '@tauri-apps/api/event';
export function setupCacheProgressListener() {
@ -30,8 +31,21 @@ export function setupCacheProgressListener() {
});
}
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
function loadRecentLocal(): Song[] {
try {
const raw = localStorage.getItem('recent_local');
if (raw) return JSON.parse(raw);
} catch {}
return [];
}
function loadLikedIdsFromStorage(): Set<number> {
try {
const raw = localStorage.getItem('liked_ids');
if (raw) return new Set(JSON.parse(raw));
} catch {}
return new Set();
}
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref<Song | null>(null);
@ -41,9 +55,60 @@ export const usePlayerStore = defineStore('player', () => {
const queue = ref<Song[]>([]);
const currentIndex = ref(-1);
const volume = ref(100);
let tickInterval: ReturnType<typeof setInterval> | null = null;
const recentLocal = ref<Song[]>(loadRecentLocal());
const MAX_RECENT = 200;
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage());
function isLiked(songId: number): boolean {
return likedIds.value.has(songId);
}
async function loadLikedIds() {
const userStore = useUserStore();
if (!userStore.isLoggedIn) return;
try {
const json: string = await invoke('likelist', { uid: userStore.user!.userId });
const data = JSON.parse(json);
const ids: number[] = data.ids || data.data?.ids || [];
likedIds.value = new Set(ids);
} catch { /* 忽略 */ }
}
async function toggleLike(songId: number) {
const wasLiked = likedIds.value.has(songId);
const newLike = !wasLiked;
try {
await invoke('like_song', { query: { id: songId, like: newLike ? 'true' : 'false' } });
if (newLike) {
likedIds.value.add(songId);
} else {
likedIds.value.delete(songId);
}
likedIds.value = new Set(likedIds.value);
} catch { /* 忽略 */ }
}
function addRecent(song: Song) {
recentLocal.value = recentLocal.value.filter(s => s.id !== song.id);
recentLocal.value.unshift(song);
if (recentLocal.value.length > MAX_RECENT) {
recentLocal.value = recentLocal.value.slice(0, MAX_RECENT);
}
}
watch(recentLocal, (val) => {
localStorage.setItem('recent_local', JSON.stringify(val));
}, { deep: true });
watch(likedIds, (val) => {
localStorage.setItem('liked_ids', JSON.stringify([...val]));
}, { deep: true });
const isFmMode = ref(false);
let fmNextCallback: (() => void) | null = null;
@ -57,12 +122,14 @@ export const usePlayerStore = defineStore('player', () => {
fmNextCallback = null;
}
// 播放私人漫游歌曲(清空队列,只播放这一首)
let fmVipSkipCount = 0;
const MAX_FM_VIP_SKIP = 10;
async function playFmSong(song: any) {
// 如果缺少时长,尝试从详情接口获取
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
if (!song.dt || song.dt === 0) {
try {
const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) });
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
const data = JSON.parse(jsonStr);
const full = data.songs?.[0];
if (full) {
@ -78,28 +145,57 @@ export const usePlayerStore = defineStore('player', () => {
currentIndex.value = -1;
playing.value = false;
fmSong.value = song;
currentSong.value = song;
try {
const url: string = await invoke('get_song_url', { id: Number(song.id) });
const settings = useSettingsStore();
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality, fm_mode: true } });
const data = JSON.parse(jsonStr);
const url: string | undefined = data.url;
if (!url) throw new Error('无播放源');
if (data.freeTrialInfo) {
console.warn('FM VIP 试听歌曲,自动跳过', song.name);
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
fmVipSkipCount++;
if (fmVipSkipCount >= MAX_FM_VIP_SKIP) {
console.warn('FM 连续跳过 VIP 歌曲过多,停止');
fmVipSkipCount = 0;
disableFmMode();
return;
}
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
return;
}
fmVipSkipCount = 0;
await invoke('play_audio', { url });
await waitForAudioStart();
playing.value = true;
duration.value = (song.dt || 0) / 1000;
currentTime.value = 0;
startTick();
addRecent(song);
} catch (e) {
console.error('FM播放失败', e);
playing.value = false;
if (fmNextCallback) {
fmNextCallback();
} else {
disableFmMode();
}
}
}
// 播放指定歌曲(如果不在队列中则加入并切换)
async function play(song: Song) {
disableFmMode();
const idx = queue.value.findIndex(s => s.id === song.id);
if (idx === -1) {
// 未在队列中,添加到队列并播放该位置
queue.value.push(song);
currentIndex.value = queue.value.length - 1;
} else {
@ -108,7 +204,34 @@ export const usePlayerStore = defineStore('player', () => {
await playCurrent();
}
async function playFromList(songs: Song[], startIndex: number) {
disableFmMode();
if (songs.length === 0) return;
queue.value = [...songs];
currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1));
await playCurrent();
}
let vipSkipCount = 0;
const MAX_VIP_SKIP = 10;
let audioStartedResolve: (() => void) | null = null;
listen('audio-started', () => {
if (audioStartedResolve) {
audioStartedResolve();
audioStartedResolve = null;
}
});
function waitForAudioStart(): Promise<void> {
return new Promise<void>((resolve) => {
audioStartedResolve = resolve;
});
}
async function playCurrent() {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
const song = queue.value[currentIndex.value];
if (!song?.id) {
console.error('无效的歌曲数据', song);
@ -116,22 +239,49 @@ export const usePlayerStore = defineStore('player', () => {
}
try {
// 重置状态
currentSong.value = song;
playing.value = false;
currentTime.value = 0;
duration.value = (song.dt || 0) / 1000;
duration.value = (song.dt || song.duration || 0) / 1000;
if (song.localPath) {
await invoke('play_local_audio', { path: song.localPath });
await waitForAudioStart();
playing.value = true;
startTick();
addRecent(song);
return;
}
const settings = useSettingsStore();
const jsonStr: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } });
const data = JSON.parse(jsonStr);
const url: string | undefined = data.url;
// 获取 URL 并播放
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) {
console.error('未获取到有效播放地址', song);
return;
}
if (data.freeTrialInfo) {
console.warn('VIP 试听歌曲,自动跳过', song.name);
showToast(`${song.name} 为 VIP 试听,已跳过`, 'info');
vipSkipCount++;
if (vipSkipCount >= MAX_VIP_SKIP) {
console.warn('连续跳过 VIP 歌曲过多,停止跳过');
vipSkipCount = 0;
return;
}
next();
return;
}
await invoke('play_audio', { url });
await waitForAudioStart();
playing.value = true;
startTick();
addRecent(song);
vipSkipCount = 0;
} catch (e) {
console.error('播放失败', e);
playing.value = false;
@ -145,7 +295,8 @@ export const usePlayerStore = defineStore('player', () => {
currentTime.value += 0.25;
if (currentTime.value >= duration.value) {
currentTime.value = duration.value;
next(); // 自动下一首
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
next();
}
}
}, 250);
@ -167,7 +318,7 @@ export const usePlayerStore = defineStore('player', () => {
currentSong.value = null;
currentTime.value = 0;
if (tickInterval) clearInterval(tickInterval);
disableFmMode(); // 停止时退出漫游
disableFmMode();
}
@ -178,7 +329,6 @@ export const usePlayerStore = defineStore('player', () => {
playCurrent();
}
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
async function playAll(songs: Song[]) {
if (songs.length === 0) return;
queue.value = [...songs];
@ -190,22 +340,17 @@ export const usePlayerStore = defineStore('player', () => {
if (index < 0 || index >= queue.value.length) return;
const isCurrent = index === currentIndex.value;
if (isCurrent) {
// 如果移除的是当前正在播放的歌曲,先停止,然后调整索引
stop();
queue.value.splice(index, 1);
// 如果队列变空,则重置
if (queue.value.length === 0) {
currentIndex.value = -1;
return;
}
// 保持索引不变,但如果删的是最后一个,索引需要退一位
if (currentIndex.value >= queue.value.length) {
currentIndex.value = queue.value.length - 1;
}
// 不自动播放,等用户手动选择
} else {
queue.value.splice(index, 1);
// 调整当前索引
if (index < currentIndex.value) {
currentIndex.value -= 1;
}
@ -227,15 +372,19 @@ export const usePlayerStore = defineStore('player', () => {
}
}
async function adjustVolume(delta: number) {
const newVol = Math.max(0, Math.min(100, volume.value + delta));
volume.value = newVol;
await invoke('set_volume', { vol: newVol / 100 });
}
// 在 defineStore 内部添加
const playMode = ref<PlayMode>('loop');
function setPlayMode(mode: PlayMode) {
playMode.value = mode;
}
// 重写 next() 以根据模式选择下一首
function next() {
if (isFmMode.value && fmNextCallback) {
fmNextCallback();
@ -246,11 +395,9 @@ export const usePlayerStore = defineStore('player', () => {
let nextIndex: number;
switch (playMode.value) {
case 'repeat-one':
// 单曲循环,不改变索引,只重新播放当前
playCurrent();
return;
case 'shuffle':
// 随机下一首,且不与当前重复(除非只剩一首)
if (queue.value.length === 1) {
nextIndex = 0;
} else {
@ -261,7 +408,6 @@ export const usePlayerStore = defineStore('player', () => {
break;
case 'loop':
default:
// 顺序循环
nextIndex = (currentIndex.value + 1) % queue.value.length;
break;
}
@ -279,6 +425,10 @@ export const usePlayerStore = defineStore('player', () => {
showRoamDrawer.value = false;
}
function toggleRoamDrawer() {
showRoamDrawer.value = !showRoamDrawer.value;
}
async function loadFirstFmSong() {
try {
const jsonStr: string = await invoke('personal_fm');
@ -286,7 +436,7 @@ export const usePlayerStore = defineStore('player', () => {
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
enableFmMode(() => loadFirstFmSong()); // 下一首回调
enableFmMode(() => loadFirstFmSong());
await playFmSong(song);
return true;
}
@ -309,10 +459,9 @@ async function loadFm() {
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
fmSong.value = song;
enableFmMode(nextFm); // 设置下一首回调为 store 内的 nextFm
await playFmSong(song); // 使用 FM 专用播放方法
enableFmMode(nextFm);
await playFmSong(song);
fmPlaying.value = true;
// showRoamDrawer.value = true; // 自动打开全屏抽屉
}
} catch (e) {
console.error('FM加载失败', e);
@ -322,17 +471,13 @@ async function loadFm() {
async function toggleFm() {
if (!fmSong.value) return;
if (fmPlaying.value) {
// 当前 FM 正在播放,切换暂停/恢复
await toggle(); // 全局暂停/播放
await toggle();
fmPlaying.value = playing.value;
} else {
// FM 处于暂停状态,或者当前被其他歌曲打断
if (currentSong.value?.id === fmSong.value.id) {
// FM 歌曲还是当前歌曲,直接恢复
await toggle();
fmPlaying.value = playing.value;
} else {
// 当前播放的是其他歌曲,重新以 FM 模式播放 FM 歌曲
enableFmMode(nextFm);
await playFmSong(fmSong.value);
fmPlaying.value = true;
@ -341,20 +486,28 @@ async function toggleFm() {
}
async function nextFm() {
await loadFm(); // 加载下一首 FM 歌曲
await loadFm();
}
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
listen('audio-ended', () => {
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
if (isFmMode.value && fmNextCallback) {
fmNextCallback();
return;
}
if (playing.value && !isFmMode.value) {
next();
}
});
watch(currentSong, (newSong) => {
if (isFmMode.value && newSong?.id !== fmSong.value?.id) {
fmPlaying.value = false;
// 注意:不调用 disableFmMode,因为可能只是临时切歌,但卡片需要知道 FM 已停止
disableFmMode(); // 退出 FM 模式,让上一首按钮恢复
disableFmMode();
}
});
watch(playing, (val) => {
// 只有当前正在播放的是 FM 歌曲时,才同步 fmPlaying
if (currentSong.value?.id === fmSong.value?.id) {
fmPlaying.value = val;
} else {
@ -377,6 +530,7 @@ watch(playing, (val) => {
playFmSong,
setPlayMode,
play,
playFromList,
playAll,
toggle,
stop,
@ -384,13 +538,23 @@ watch(playing, (val) => {
next,
seek,
playCurrent,
volume,
adjustVolume,
removeFromQueue,
clearQueue,
recentLocal,
likedIds,
isLiked,
loadLikedIds,
toggleLike,
showRoamDrawer,
openRoamDrawer,
closeRoamDrawer,
toggleRoamDrawer,
loadFirstFmSong,
fmSong,
@ -399,4 +563,4 @@ watch(playing, (val) => {
toggleFm,
nextFm,
};
});
});

135
src/stores/settings.ts Normal file
View File

@ -0,0 +1,135 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires';
export type ThemeMode = 'dark' | 'light';
export type CloseAction = 'ask' | 'minimize' | 'exit';
export const qualityLabels: Record<AudioQuality, string> = {
standard: '标准',
higher: '较高',
exhigh: '极高 (HQ)',
lossless: '无损 (SQ)',
hires: 'Hi-Res',
};
export const closeActionLabels: Record<CloseAction, string> = {
ask: '每次询问',
minimize: '最小化到托盘',
exit: '直接退出',
};
export interface ShortcutBinding {
key: string;
label: string;
}
export const defaultShortcuts: Record<string, ShortcutBinding> = {
prev: { key: 'Control+ArrowLeft', label: '上一首' },
next: { key: 'Control+ArrowRight', label: '下一首' },
volUp: { key: 'Control+ArrowUp', label: '音量增加' },
volDown: { key: 'Control+ArrowDown', label: '音量减小' },
globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' },
globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' },
globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' },
globalVolDown: { key: 'Alt+Control+ArrowDown', label: '音量减小(全局)' },
};
interface SettingsData {
audioQuality: AudioQuality;
downloadPath: string;
theme: ThemeMode;
closeAction: CloseAction;
shortcuts: Record<string, ShortcutBinding>;
}
function loadSettings(): SettingsData {
try {
const raw = localStorage.getItem('app_settings');
if (raw) {
const parsed = JSON.parse(raw);
return {
audioQuality: parsed.audioQuality || 'standard',
downloadPath: parsed.downloadPath || '',
theme: parsed.theme || 'dark',
closeAction: parsed.closeAction || 'ask',
shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) },
};
}
} catch {}
return {
audioQuality: 'standard',
downloadPath: '',
theme: 'dark',
closeAction: 'ask',
shortcuts: { ...defaultShortcuts },
};
}
export const useSettingsStore = defineStore('settings', () => {
const saved = loadSettings();
const audioQuality = ref<AudioQuality>(saved.audioQuality);
const downloadPath = ref<string>(saved.downloadPath);
const theme = ref<ThemeMode>(saved.theme);
const closeAction = ref<CloseAction>(saved.closeAction || 'ask');
const shortcuts = ref<Record<string, ShortcutBinding>>(saved.shortcuts);
function setAudioQuality(q: AudioQuality) {
audioQuality.value = q;
}
function setDownloadPath(p: string) {
downloadPath.value = p;
}
function setTheme(t: ThemeMode) {
theme.value = t;
}
function setCloseAction(a: CloseAction) {
closeAction.value = a;
}
function setShortcut(id: string, key: string) {
shortcuts.value = { ...shortcuts.value, [id]: { ...shortcuts.value[id], key } };
}
function resetShortcuts() {
shortcuts.value = { ...defaultShortcuts };
}
function resetAll() {
audioQuality.value = 'standard';
downloadPath.value = '';
theme.value = 'dark';
closeAction.value = 'ask';
shortcuts.value = { ...defaultShortcuts };
}
watch([audioQuality, downloadPath, theme, closeAction, shortcuts], () => {
const data: SettingsData = {
audioQuality: audioQuality.value,
downloadPath: downloadPath.value,
theme: theme.value,
closeAction: closeAction.value,
shortcuts: shortcuts.value,
};
localStorage.setItem('app_settings', JSON.stringify(data));
}, { deep: true });
return {
audioQuality,
downloadPath,
theme,
closeAction,
shortcuts,
setAudioQuality,
setDownloadPath,
setTheme,
setCloseAction,
setShortcut,
resetShortcuts,
resetAll,
};
});

View File

@ -1,14 +1,74 @@
@import "tailwindcss";
@theme {
--color-base: var(--c-bg);
--color-surface: var(--c-surface);
--color-subtle: var(--c-subtle);
--color-muted: var(--c-muted);
--color-emphasis: var(--c-emphasis);
--color-content: var(--c-content);
--color-content-2: var(--c-content-2);
--color-content-3: var(--c-content-3);
--color-content-4: var(--c-content-4);
--color-line: var(--c-line);
--color-line-2: var(--c-line-2);
--color-accent: var(--c-accent);
--color-accent-hover: var(--c-accent-hover);
--color-accent-text: var(--c-accent-text);
--color-accent-dim: var(--c-accent-dim);
--color-danger: var(--c-danger);
--color-danger-dim: var(--c-danger-dim);
--color-warning: var(--c-warning);
--color-info: var(--c-info);
}
@layer base {
:root {
--color-surface: 255 255 255;
--color-primary: 34 197 94;
--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);
--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-accent: #22c55e;
--c-accent-hover: #16a34a;
--c-accent-text: #4ade80;
--c-accent-dim: rgba(34, 197, 94, 0.20);
--c-danger: #ef4444;
--c-danger-dim: rgba(239, 68, 68, 0.20);
--c-warning: #eab308;
--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;
--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;
}
/* 确保 html 也应用暗色背景,防止空白区域 */
html {
background: #0f172a;
background: var(--c-bg);
overflow: hidden;
height: 100%;
overscroll-behavior: none;
@ -17,17 +77,14 @@
body {
@apply antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
/* 关键:锁住 body彻底消除整体拖动 */
background: var(--c-bg);
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
/* 阻止触控板手势触发页面导航 */
touch-action: none;
}
/* 自定义滚动条保持不变 */
::-webkit-scrollbar {
width: 5px;
height: 5px;
@ -36,10 +93,64 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
background-color: var(--c-muted);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
background-color: var(--c-emphasis);
}
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
cursor: pointer;
}
select:focus {
border-color: var(--c-accent);
box-shadow: 0 0 0 2px var(--c-accent-dim);
}
select option {
background: var(--c-surface);
color: var(--c-content);
padding: 8px;
}
input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
border: 2px solid var(--c-emphasis);
border-radius: 4px;
background: transparent;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
flex-shrink: 0;
}
input[type="checkbox"]:hover {
border-color: var(--c-accent);
}
input[type="checkbox"]:checked {
background: var(--c-accent);
border-color: var(--c-accent);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}

20
src/utils/format.ts Normal file
View File

@ -0,0 +1,20 @@
export function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function formatPlayCount(count: number): string {
if (!count) return '0';
if (count >= 100000000) return (count / 100000000).toFixed(1) + '亿';
if (count >= 10000) return (count / 10000).toFixed(1) + '万';
return count.toString();
}

View File

@ -3,14 +3,15 @@
*/
export function normalizeSong(song: any) {
const normalized = { ...song };
// 封面 / 艺术家兼容
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.al?.name && normalized.album?.name) {
normalized.al = { ...normalized.al, name: normalized.album.name };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
// 时长:只保留合理的 dt100ms ~ 2小时否则置 0
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
normalized.dt = 0;
}

View File

@ -1,26 +1,57 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
<div v-if="loading" class="text-gray-400">加载中...</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">每日推荐</h1>
<button
v-if="songs.length > 0"
@click="player.playAll(songs)"
class="px-4 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition text-sm"
>
播放全部
</button>
</div>
<div v-if="loading" class="text-content-2">加载中...</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
@click="player.playFromList(songs, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
@ -30,11 +61,18 @@
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatDuration } from '../utils/format';
const player = usePlayerStore();
const download = useDownload();
const songs = ref<any[]>([]);
const loading = ref(true);
function isCurrentSong(songId: number): boolean {
return player.currentSong?.id === songId;
}
onMounted(async () => {
try {
const jsonStr: string = await invoke('recommend_songs');
@ -46,11 +84,4 @@ onMounted(async () => {
loading.value = false;
}
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="p-8 text-white">
<div class="p-8 text-content">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<!-- 搜索框 -->
@ -7,7 +7,7 @@
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲、歌手、专辑..."
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
/>
<!-- 热门搜索标签仅在没有搜索且未显示结果时出现 -->
@ -18,7 +18,7 @@
v-for="tag in hotTags"
:key="tag.searchWord"
@click="searchTag(tag.searchWord)"
class="px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 cursor-pointer transition text-sm"
class="px-3 py-1 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
>
{{ tag.searchWord }}
</span>
@ -27,31 +27,36 @@
<!-- 输出设备选择 -->
<!-- <div class="mb-4">
<label class="mr-2 text-sm text-gray-400">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
<label class="mr-2 text-sm text-content-2">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-muted text-white rounded p-1 text-sm">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<!-- 搜索结果 -->
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-if="loading" class="text-content-2">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
v-for="(song, index) in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition"
@click="playSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ song.name }}</p>
<p class="text-sm text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
</div>
</div>
</template>
@ -63,10 +68,12 @@ import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const download = useDownload();
const keyword = ref('');
const results = ref<any[]>([]);
@ -116,8 +123,15 @@ function searchTag(tag: string) {
handleSearch();
}
async function playSong(song: any) {
player.play(song);
async function playSong(_song: any, index: number) {
const normalized = results.value.map((s: any) => ({
id: s.id,
name: s.name,
ar: s.ar || s.artists || [],
al: s.al || s.album || { picUrl: '' },
dt: s.dt || 0,
}));
player.playFromList(normalized, index);
}
</script>
</script>

View File

@ -1,6 +1,89 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4"> 我喜欢的音乐</h1>
<p class="text-gray-400">正在施工...</p>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
<button
v-if="songs.length"
@click="player.playAll(songs)"
class="px-4 py-1.5 bg-muted hover:bg-emphasis rounded-full text-sm transition"
>
播放全部
</button>
</div>
<div v-if="!userStore.isLoggedIn" class="text-content-2">
请先登录后查看喜欢的音乐
</div>
<div v-else-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.playFromList(songs, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
>
<span class="text-xs text-content-3 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { useDownload } from '../composables/useDownload';
import { normalizeSong } from '../utils/song';
import { formatDuration } from '../utils/format';
const player = usePlayerStore();
const userStore = useUserStore();
const download = useDownload();
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
if (!userStore.isLoggedIn) {
loading.value = false;
return;
}
try {
const playlistJson: string = await invoke('user_playlist', { uid: userStore.user!.userId });
const playlistData = JSON.parse(playlistJson);
const created = (playlistData.playlist || []).filter((p: any) => !p.subscribed);
if (created.length === 0) {
loading.value = false;
return;
}
const likePlaylistId = created[0].id;
const trackJson: string = await invoke('playlist_track_all', { query: { id: likePlaylistId } });
const trackData = JSON.parse(trackJson);
songs.value = (trackData.songs || []).map(normalizeSong);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="p-8 text-white">
<div class="p-8 text-content">
<!-- 第一行每日推荐 & 私人漫游 卡片 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<div class="grid grid-cols-2 gap-6 mb-10">
<!-- 每日推荐 -->
<div
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
@ -19,76 +19,61 @@
</div>
<!-- 私人漫游 卡片 -->
<!-- 私人漫游 卡片 -->
<div
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
@click="!userStore.isLoggedIn ? goLogin() : null"
>
<!-- 模糊封面层仅在有歌曲且有封面时显示低透明度模糊 -->
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center opacity-30 blur-md scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition"></div>
<!-- 内容 -->
<div class="relative z-10 h-full">
<!-- 未登录 -->
<div v-if="!userStore.isLoggedIn" class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-white/60 mb-1">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<p class="text-xs text-white/60 mt-2">登录后即可开启沉浸式音乐探索</p>
</div>
<!-- 登录后无歌曲 垂直居中播放按钮 -->
<div
v-else-if="!player.fmSong"
class="flex flex-col items-center justify-center h-full gap-3 cursor-pointer"
@click.stop="startFmPlay"
>
<p class="text-xs text-white/60">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<button
class="w-12 h-12 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition mt-2"
<div
class="h-48 rounded-3xl overflow-hidden relative group select-none cursor-pointer"
:class="player.fmSong && fmCoverUrl ? '' : 'bg-gradient-to-br from-indigo-600 via-blue-600 to-cyan-500'"
@click="onFmCardClick"
>
<svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
</div>
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-black/10 group-hover:from-black/60 transition"></div>
<!-- 有歌曲 横向布局左侧信息右侧按钮 -->
<div v-else class="flex items-center justify-between h-full px-6 cursor-pointer" @click.stop="player.toggleFm">
<!-- 左侧封面 + 歌曲信息 -->
<div class="flex items-center gap-3 min-w-0">
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
<div class="min-w-0">
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
<div class="relative z-10 h-full flex flex-col justify-between p-6">
<div class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white/50"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
<span class="text-xs text-white/50 font-medium">私人漫游</span>
</div>
<div class="flex items-end justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-xl font-bold" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
<h2 class="text-xl font-bold" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
<h2 class="text-lg font-bold truncate" v-else>{{ fmDisplayName }}</h2>
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好为你推荐意想不到的好歌</p>
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button v-if="userStore.isLoggedIn && !player.fmSong"
@click.stop="startFmPlay"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<template v-if="player.fmSong">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click.stop="player.nextFm"
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-white"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</button>
</template>
</div>
</div>
</div>
</div>
<!-- 右侧控制按钮 -->
<div class="flex items-center gap-3 ml-4">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition"
>
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click.stop="player.nextFm" class="text-xl text-white/80 hover:text-white transition"></button>
</div>
</div>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20 pointer-events-none">🌊</div>
</div>
</div>
@ -97,11 +82,11 @@
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer">
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer">
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
<p class="text-xs text-content-2 mt-1">{{ pl.copywriter || '' }}</p>
</div>
</div>
</div>
@ -112,7 +97,7 @@
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer backdrop-blur-sm">
class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm">
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
@ -155,15 +140,25 @@ const fmDisplayArtists = computed(() => {
// 首次点击播放按钮:开始 FM 并播放
async function startFmPlay() {
// 如果还没加载过 FM或者之前加载了但被停止了重新加载
if (!player.fmSong) {
await player.loadFm(); // loadFm 内部会设置 fmSong 并播放
await player.loadFm();
} else {
// 已有歌曲但未播放状态(比如之前暂停/停止了),直接播放
await player.toggleFm();
}
}
function onFmCardClick() {
if (!userStore.isLoggedIn) {
goLogin();
return;
}
if (!player.fmSong) {
startFmPlay();
return;
}
player.openRoamDrawer();
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;

225
src/views/LocalMusic.vue Normal file
View File

@ -0,0 +1,225 @@
<template>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<button
v-if="songs.length"
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
刷新
</button>
</div>
<div v-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-3">
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id + '-' + index"
@click="playLocalSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
:class="{ 'bg-subtle': player.currentSong?.id === song.id }"
>
<span class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-muted">
<img v-if="song.cover" :src="song.cover" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.artist }}<template v-if="song.album"> · {{ song.album }}</template>
</p>
</div>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.duration) }}</span>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(song.id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
<Transition name="fade">
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
从磁盘中删除
</button>
</div>
</Transition>
</div>
</div>
</div>
<Transition name="fade">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDeleteConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认删除</h2>
<p class="text-sm text-content-2 mb-5">确定要删除{{ deleteTarget?.name }}此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showDeleteConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="doDelete"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
删除
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore, type Song } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
const player = usePlayerStore();
const download = useDownload();
const settings = useSettingsStore();
interface LocalSong {
id: number;
name: string;
artist: string;
album: string;
duration: number;
cover: string | null;
filename: string;
fileSize: number;
path: string;
local: boolean;
}
const songs = ref<LocalSong[]>([]);
const loading = ref(true);
const showDeleteConfirm = ref(false);
const deleteTarget = ref<LocalSong | null>(null);
const openMenuId = ref<number | null>(null);
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
function closeMenu() {
openMenuId.value = null;
}
onMounted(() => { document.addEventListener('click', closeMenu); });
onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
async function refresh() {
loading.value = true;
try {
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
songs.value = list;
fetchMissingCovers();
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function fetchMissingCovers() {
const missing = songs.value.filter(s => !s.cover && s.id > 0 && s.id < 1e12);
if (missing.length === 0) return;
const ids = [...new Set(missing.map(s => s.id))];
try {
const jsonStr: string = await invoke('get_song_detail', { id: JSON.stringify(ids) });
const data = JSON.parse(jsonStr);
const detailMap = new Map<number, string>();
for (const s of data.songs || []) {
const url = s.al?.picUrl;
if (url && s.id) detailMap.set(s.id, url + '?param=100y100');
}
for (const song of missing) {
const url = detailMap.get(song.id);
if (url) song.cover = url;
}
} catch {}
}
onMounted(refresh);
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function formatDuration(ms: number): string {
if (!ms || ms === 0) return '--:--';
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
function toSong(local: LocalSong): Song {
return {
id: local.id,
name: local.name,
ar: local.artist ? [{ name: local.artist }] : [],
al: { picUrl: local.cover || '', name: local.album || undefined },
dt: local.duration || undefined,
artists: local.artist ? [{ name: local.artist }] : [],
album: { picUrl: local.cover || undefined, name: local.album || undefined },
duration: local.duration || undefined,
localPath: local.path,
};
}
async function playLocalSong(_song: LocalSong, index: number) {
const normalized = songs.value.map(s => toSong(s));
player.playFromList(normalized, index);
}
function confirmDelete(song: LocalSong) {
openMenuId.value = null;
deleteTarget.value = song;
showDeleteConfirm.value = true;
}
async function doDelete() {
if (!deleteTarget.value) return;
try {
await invoke('delete_local_song', { query: { id: deleteTarget.value.id, filename: deleteTarget.value.filename, downloadPath: settings.downloadPath || null } });
songs.value = songs.value.filter(s => s.id !== deleteTarget.value!.id);
download.localSongIds.delete(deleteTarget.value.id);
showToast('已删除', 'success');
} catch (e) {
showToast('删除失败', 'error');
}
showDeleteConfirm.value = false;
deleteTarget.value = null;
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -1,21 +1,19 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
<div class="min-h-screen flex items-center justify-center bg-base text-content">
<div class="bg-subtle backdrop-blur-md border border-line p-8 rounded-2xl w-full max-w-sm text-center">
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
<!-- 二维码展示区 -->
<p class="text-sm text-content-2 mb-6">请使用网易云音乐 App 扫描二维码</p>
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
</div>
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
<div v-else class="w-48 h-48 bg-subtle rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-content-2">加载中...</span>
<span v-else-if="qrError" class="text-danger text-sm">{{ qrError }}</span>
</div>
<!-- 状态提示 -->
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
<button @click="refreshQr" class="mt-4 text-xs text-accent-text hover:underline">重新获取二维码</button>
</div>
</div>
</template>
@ -33,7 +31,7 @@ const qrimg = ref('');
const qrLoading = ref(true);
const qrError = ref('');
const statusText = ref('等待扫码...');
const statusColor = ref('text-gray-400');
const statusColor = ref('text-content-2');
let qrKey = '';
let pollTimer: ReturnType<typeof setInterval> | null = null;
@ -54,7 +52,6 @@ async function refreshQr() {
qrError.value = '';
if (pollTimer) clearInterval(pollTimer);
try {
// 1. 获取 unikey
qrKey = await invoke('get_qr_key');
if (!qrKey) {
qrError.value = '未获取到登录密钥';
@ -62,16 +59,13 @@ async function refreshQr() {
return;
}
// 2. 拼接网易云标准扫码链接(无需 create_qr
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
// 3. 用 qrcode 生成二维码图片
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
qrimg.value = canvas.toDataURL('image/png');
qrLoading.value = false;
// 4. 开始轮询状态
startPolling();
} catch (e: any) {
qrError.value = '获取二维码失败';
@ -79,20 +73,6 @@ async function refreshQr() {
}
}
// 新增函数:用 Canvas 生成二维码并赋值给 qrimg
// async function drawQrCode(url: string) {
// try {
// // 等待 DOM 准备好 canvas 元素
// const canvas = document.createElement('canvas');
// await QRCode.toCanvas(canvas, url, { width: 201, margin: 1 });
// // 转为 data URL 赋值给响应式的图片地址
// qrimg.value = canvas.toDataURL('image/png');
// } catch (e) {
// console.error('生成二维码失败', e);
// qrError.value = '生成二维码失败';
// }
// }
function startPolling() {
pollTimer = setInterval(async () => {
try {
@ -101,23 +81,18 @@ function startPolling() {
const code = data.code;
if (code === 800) {
statusText.value = '二维码已过期,请刷新';
statusColor.value = 'text-red-400';
statusColor.value = 'text-danger';
clearInterval(pollTimer!);
} else if (code === 801) {
statusText.value = '等待扫码...';
statusColor.value = 'text-gray-400';
statusColor.value = 'text-content-2';
} else if (code === 802) {
statusText.value = '请在手机上确认登录';
statusColor.value = 'text-yellow-400';
statusColor.value = 'text-warning';
} else if (code === 803) {
// 登录成功
clearInterval(pollTimer!);
statusText.value = '登录成功!';
statusColor.value = 'text-green-400';
// 存储 cookie 到 NcmApi后台线程中自动保留后续请求都带登录态
// 获取用户信息(简化,可从 /login/status 获取)
// 这里需要额外调用获取用户详情的 API但因为 NcmApi 已有 cookie可以直接在后台线程中添加
// 暂时用简易方式:调用 /user/account 获取用户简档
statusColor.value = 'text-accent-text';
await fetchUserProfile();
setTimeout(() => router.push('/'), 500);
}
@ -129,9 +104,6 @@ function startPolling() {
async function fetchUserProfile() {
try {
// 添加一个快速获取用户信息的命令(可复用之前的 login 命令中获取 profile 的逻辑)
// 这里简化,由于后台 NcmApi 已有 cookie我们可以直接用 reqwest 调 /user/account
// 但最好添加一个新命令,这里直接调用现有的 login 逻辑不适用,因此我们在 Rust 侧添加一个 get_login_status 命令
const profileJson: string = await invoke('get_login_status');
const profile = JSON.parse(profileJson);
if (profile.profile) {
@ -145,4 +117,4 @@ async function fetchUserProfile() {
console.error('获取用户信息失败', e);
}
}
</script>
</script>

View File

@ -1,91 +1,166 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<!-- 歌单信息 -->
<div v-if="playlist" class="flex gap-6 mb-8">
<img :src="playlist.coverImgUrl" class="w-40 h-40 rounded-xl object-cover shadow-lg" />
<div>
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
<p class="text-xs text-gray-500 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }}
</p>
<button
@click="playAll"
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
>
播放全部
</button>
<img :src="playlist.coverImgUrl" class="w-44 h-44 rounded-xl object-cover shadow-lg flex-shrink-0" />
<div class="flex flex-col justify-between min-w-0">
<div>
<h1 class="text-2xl font-bold leading-tight">{{ playlist.name }}</h1>
<div v-if="playlist.creator" class="flex items-center gap-2 mt-2">
<img :src="playlist.creator.avatarUrl" class="w-5 h-5 rounded-full" />
<span class="text-sm text-content-2">{{ playlist.creator.nickname }}</span>
</div>
<p class="text-sm text-content-2 mt-2 line-clamp-2">{{ playlist.description }}</p>
<p class="text-xs text-content-3 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ formatPlayCount(playlist.playCount) }}
</p>
</div>
<div class="flex items-center gap-3 mt-4">
<button
@click="playAll"
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
播放全部
</button>
<button
v-if="!isOwnPlaylist"
@click="toggleSubscribe"
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path v-if="subscribed" d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
<path v-else d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
</svg>
{{ subscribed ? '已收藏' : '收藏歌单' }}
</button>
</div>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="text-gray-400">加载中...</div>
<div v-if="loading" class="text-content-2">加载中...</div>
<!-- 歌曲列表 -->
<div v-else class="space-y-2">
<div v-else class="space-y-1">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="playSingle(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer group"
:class="{ 'bg-accent-dim': isCurrentSong(song.id) }"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrentSong(song.id)" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ index + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
<p class="text-sm font-medium truncate" :class="isCurrentSong(song.id) ? 'text-accent-text' : ''">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { useDownload } from '../composables/useDownload';
import { showToast } from '../composables/useToast';
import { formatDuration, formatPlayCount } from '../utils/format';
const route = useRoute();
const player = usePlayerStore();
const userStore = useUserStore();
const download = useDownload();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
const loading = ref(true);
const subscribed = ref(false);
onMounted(async () => {
const id = Number(route.params.id);
const isOwnPlaylist = computed(() => {
if (!playlist.value || !userStore.user) return false;
return playlist.value.creator?.userId === userStore.user.userId;
});
async function fetchPlaylist(id: number) {
loading.value = true;
playlist.value = null;
songs.value = [];
try {
const jsonStr: string = await invoke('get_playlist_detail', { id });
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
songs.value = data.playlist.tracks || [];
subscribed.value = data.playlist.subscribed || false;
} catch (e) {
console.error('获取歌单详情失败', e);
console.error(e);
showToast('获取歌单详情失败', 'error');
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchPlaylist(Number(route.params.id));
});
function formatDuration(ms: number): string {
const sec = Math.floor(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
watch(() => route.params.id, (newId) => {
if (newId) fetchPlaylist(Number(newId));
});
function isCurrentSong(songId: number): boolean {
return player.currentSong?.id === songId;
}
async function playSingle(song: any) {
player.play(song);
const idx = songs.value.findIndex((s: any) => s.id === song.id);
player.playFromList(songs.value, idx >= 0 ? idx : 0);
}
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>
async function toggleSubscribe() {
if (!playlist.value) return;
const newSubscribed = !subscribed.value;
try {
await invoke('playlist_subscribe', { query: { id: Number(playlist.value.id), subscribe: newSubscribed } });
subscribed.value = newSubscribed;
showToast(subscribed.value ? '已收藏歌单' : '已取消收藏', 'success');
} catch {
showToast('操作失败,请稍后重试', 'error');
}
}
</script>

View File

@ -1,6 +1,45 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
<p class="text-gray-400">正在施工...</p>
<div class="p-8 text-content">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<h1 class="text-2xl font-bold mb-6">最近播放</h1>
<div v-if="player.recentLocal.length === 0" class="text-content-3">还没有播放记录去听首歌吧</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in player.recentLocal"
:key="song.id"
@click="player.playFromList(player.recentLocal, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
>
<span class="text-xs text-content-3 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<span class="text-xs text-content-3">{{ formatDuration(song.dt ?? 0) }}</span>
</div>
</div>
</div>
</template>
</template>
<script setup lang="ts">
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { formatDuration } from '../utils/format';
const player = usePlayerStore();
const download = useDownload();
</script>

View File

@ -1,51 +1,52 @@
<template>
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
<!-- 无歌曲时提示 -->
<div class="p-8 text-content flex flex-col items-center justify-center min-h-full">
<div v-if="!currentSong" class="text-center">
<p class="text-gray-400 mb-4">私人漫游未启动</p>
<p class="text-content-2 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
class="px-6 py-2 bg-muted hover:bg-emphasis rounded-full transition"
>
开始漫游
</button>
</div>
<!-- 歌曲信息展示 -->
<template v-else>
<!-- 专辑封面 -->
<img
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
v-if="coverUrl && !coverError"
:src="coverUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
@error="coverError = true"
/>
<div
v-else
class="w-80 h-80 rounded-3xl bg-muted flex items-center justify-center shadow-2xl mb-8"
>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<!-- 歌曲名和艺术家 -->
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-gray-400 mb-8">
<p class="text-lg text-content-2 mb-8">
{{ artists }}
</p>
<!-- 控制按钮 -->
<div class="flex items-center gap-8">
<button
@click="togglePlay"
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
@click="player.toggle()"
class="w-16 h-16 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis"
>
<!-- 暂停图标 -->
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<!-- 播放图标 -->
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button
@click="nextSong"
class="text-3xl text-gray-400 hover:text-white transition"
class="text-content-2 hover:text-content transition"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</button>
</div>
</template>
@ -53,28 +54,34 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { ref, computed, watch, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song';
const player = usePlayerStore();
const coverError = ref(false);
// 当前正在播放的歌曲如果处于FM模式则显示当前歌曲
const currentSong = computed(() => {
// FM 模式下直接显示正在播放的歌曲可能是FM歌曲
if (player.isFmMode && player.currentSong) {
return player.currentSong;
}
return null;
});
const coverUrl = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.al?.picUrl || currentSong.value.album?.picUrl || '';
});
watch(coverUrl, () => { coverError.value = false; });
const artists = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 进入页面时如果FM未启动自动开始
onMounted(async () => {
if (!player.isFmMode || !player.currentSong) {
await startFm();
@ -96,31 +103,7 @@ async function startFm() {
}
}
function normalizeSong(song: any) {
const normalized = { ...song };
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
return normalized;
}
async function togglePlay() {
if (player.playing) {
await invoke('pause_audio');
} else {
if (player.currentSong) {
// 恢复播放
await invoke('resume_audio');
} else {
await startFm();
}
}
}
async function nextSong() {
await startFm();
}
</script>
</script>

View File

@ -1,40 +1,36 @@
<template>
<div class="text-white">
<div class="text-content">
<h1 class="text-2xl font-bold mb-4">搜索</h1>
<!-- 输出设备选择-->
<!-- <div class="mb-4">
<label class="mr-2">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲..."
class="mb-6 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
class="mb-6 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
/>
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-if="loading" class="text-content-2">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
v-for="(song, index) in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
@click="playSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ song.name }}</p>
<p class="text-sm text-content-2 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<button @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
</div>
</div>
@ -48,6 +44,7 @@ import { watch } from 'vue';
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useRouter } from 'vue-router';
const router = useRouter();
@ -56,16 +53,15 @@ const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const player = usePlayerStore();
const download = useDownload();
const route = useRoute();
// 监听从首页或其他地方传来的 query 参数,自动搜索
watch(
() => route.query.q,
(newQ) => {
if (newQ) {
keyword.value = newQ as string;
handleSearch();
// 清除 query防止刷新后重复搜索
router.replace({ query: {} });
}
},
@ -87,22 +83,24 @@ async function handleSearch() {
}
}
async function playSong(song: any) {
async function playSong(_song: any, index: number) {
try {
await player.play(song);
const normalized = results.value.map((s: any) => ({
id: s.id,
name: s.name,
ar: s.ar || s.artists || [],
al: s.al || s.album || { picUrl: '' },
dt: s.dt || 0,
}));
await player.playFromList(normalized, index);
} catch (e) {
alert('暂无播放源或需登录');
}
}
const devices = ref<string[]>([]);
// const selectedDevice = ref<string | null>(null);
onMounted(async () => {
devices.value = await invoke('get_output_devices');
});
// async function changeDevice() {
// await invoke('set_output_device', { device: selectedDevice.value });
// }
</script>
</script>

376
src/views/Settings.vue Normal file
View File

@ -0,0 +1,376 @@
<template>
<div class="p-8 text-content max-w-2xl">
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<h1 class="text-2xl font-bold mb-8">设置</h1>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">播放</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">音质选择</p>
<p class="text-xs text-content-3 mt-0.5">更高音质需要 VIP 权限</p>
</div>
<CustomSelect v-model="settings.audioQuality" :options="qualityLabels" />
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">主题</p>
<p class="text-xs text-content-3 mt-0.5">切换应用主题</p>
</div>
<div class="flex bg-subtle rounded-lg p-0.5">
<button
v-for="t in themeOptions"
:key="t.value"
@click="settings.setTheme(t.value)"
class="px-3 py-1.5 rounded-md text-sm transition"
:class="settings.theme === t.value ? 'bg-muted text-content' : 'text-content-3 hover:text-content-2'"
>
{{ t.label }}
</button>
</div>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">窗口</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">关闭窗口时</p>
<p class="text-xs text-content-3 mt-0.5">点击关闭按钮的默认行为</p>
</div>
<CustomSelect v-model="closeActionValue" :options="closeActionLabels" />
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">下载</h2>
<div class="space-y-5">
<div>
<div class="flex items-center justify-between mb-2">
<div>
<p class="text-sm font-medium">下载路径</p>
<p class="text-xs text-content-3 mt-0.5">歌曲下载保存位置</p>
</div>
</div>
<div class="flex gap-2 items-center">
<div class="flex-1 bg-subtle border border-line rounded-lg px-3 py-2 text-sm text-content-2 truncate" :title="settings.downloadPath || defaultDownloadPath">
{{ settings.downloadPath || defaultDownloadPath }}
</div>
<button
@click="pickDownloadFolder"
class="px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-accent/15 text-accent-text hover:bg-accent/25 active:scale-95"
>
选择文件夹
</button>
<button
v-if="settings.downloadPath"
@click="clearDownloadPath"
class="px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 bg-muted text-content-2 hover:bg-emphasis hover:text-content active:scale-95"
title="重置为默认路径"
>
重置
</button>
</div>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">快捷键</h2>
<div class="space-y-3">
<div
v-for="(sc, id) in settings.shortcuts"
:key="id"
class="flex items-center justify-between p-3 bg-subtle rounded-xl"
>
<div>
<p class="text-sm font-medium">{{ sc.label }}</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-if="sc.key !== defaultShortcuts[id]?.key"
@click="settings.setShortcut(id, defaultShortcuts[id].key)"
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
title="恢复默认"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button
@click="startRecording(id)"
class="px-3 py-1.5 rounded-lg text-sm transition min-w-[120px] text-center"
:class="recordingId === id ? 'bg-accent text-white' : 'bg-muted hover:bg-emphasis text-content-2'"
>
{{ recordingId === id ? '按下新快捷键...' : formatShortcut(sc.key) }}
</button>
</div>
</div>
<button
@click="resetShortcuts"
class="text-xs text-content-3 hover:text-danger transition"
>
恢复默认快捷键
</button>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">其他</h2>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-subtle rounded-xl">
<div>
<p class="text-sm font-medium">恢复默认设置</p>
<p class="text-xs text-content-3 mt-0.5">重置所有设置为初始状态</p>
</div>
<button
@click="handleResetAll"
class="px-3 py-1.5 rounded-lg text-sm bg-muted hover:bg-emphasis text-danger transition"
>
重置
</button>
</div>
</div>
</section>
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">关于</h2>
<div class="space-y-4">
<a @click.prevent="openUrl('https://gitea.atdunbg.xyz/atdunbg/Nekosonic-Music')"
class="flex items-center gap-4 p-4 bg-subtle rounded-xl hover:bg-muted transition cursor-pointer">
<img src="../assets/app-icon.png" class="w-12 h-12 rounded-xl flex-shrink-0" alt="Nekosonic" />
<div>
<p class="font-semibold">Nekosonic</p>
<p class="text-xs text-content-3 mt-0.5">版本 {{ appVersion }}</p>
</div>
</a>
<p class="text-xs text-content-3 leading-relaxed">
Nekosonic 是一款高颜值的跨平台第三方网易云音乐桌面客户端基于 Tauri 2 + Vue 3 构建提供轻量流畅的音乐播放体验
</p>
<button
@click="checkUpdate"
:disabled="checkingUpdate"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg v-if="!checkingUpdate" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
{{ checkingUpdate ? '获取中...' : '查看最新版日志' }}
</button>
<p v-if="updateMessage && !latestRelease" class="text-xs" :class="updateMessageClass">{{ updateMessage }}</p>
</div>
</section>
<Transition name="fade">
<div v-if="showResetConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showResetConfirm = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
<h2 class="text-lg font-semibold text-content mb-1">确认重置</h2>
<p class="text-sm text-content-2 mb-5">所有设置将恢复为默认值此操作不可撤销</p>
<div class="flex gap-3">
<button @click="showResetConfirm = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
取消
</button>
<button @click="confirmResetAll"
class="flex-1 py-2 rounded-lg bg-danger/20 hover:bg-danger/30 text-danger text-sm font-medium transition">
确认重置
</button>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="showUpdateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showUpdateModal = false">
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-[420px] max-h-[80vh] flex flex-col select-auto">
<div class="p-6 pb-4">
<div class="flex items-center justify-between mb-1">
<h2 class="text-lg font-semibold text-content">最新版本日志</h2>
<span v-if="latestRelease" class="text-xs font-medium px-2 py-0.5 rounded-full bg-accent/15 text-accent-text">v{{ latestRelease.tag_name?.replace('v', '') }}</span>
</div>
<p v-if="latestRelease?.published_at" class="text-xs text-content-3 mt-1">{{ formatDate(latestRelease.published_at) }}</p>
</div>
<div v-if="latestRelease?.body" class="px-6 pb-4 flex-1 overflow-y-auto max-h-60">
<div class="text-sm text-content-2 leading-relaxed whitespace-pre-wrap">{{ latestRelease.body }}</div>
</div>
<div v-else class="px-6 pb-4">
<p class="text-sm text-content-3">暂无更新日志</p>
</div>
<div class="p-4 border-t border-line flex gap-3">
<button @click="showUpdateModal = false"
class="flex-1 py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
关闭
</button>
<button v-if="latestRelease?.html_url" @click="openUrl(latestRelease.html_url)"
class="flex-1 py-2 rounded-lg bg-accent/20 hover:bg-accent/30 text-accent-text text-sm font-medium transition">
Gitea 中查看
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast';
import { invoke } from '@tauri-apps/api/core';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { open } from '@tauri-apps/plugin-dialog';
import CustomSelect from '../components/CustomSelect.vue';
const settings = useSettingsStore();
const { showToast } = useToast();
const appVersion = ref('');
const defaultDownloadPath = ref('');
onMounted(async () => {
appVersion.value = await getVersion();
try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
} catch {}
});
const closeActionValue = computed({
get: () => settings.closeAction,
set: (val: CloseAction) => settings.setCloseAction(val),
});
async function pickDownloadFolder() {
const selected = await open({
directory: true,
multiple: false,
title: '选择下载路径',
});
if (selected) {
settings.setDownloadPath(selected);
showToast('下载路径已更新', 'success');
}
}
function clearDownloadPath() {
settings.setDownloadPath('');
showToast('已重置为默认路径', 'success');
}
const checkingUpdate = ref(false);
const updateMessage = ref('');
const updateMessageClass = ref('text-content-2');
const latestRelease = ref<any>(null);
const showUpdateModal = ref(false);
const themeOptions = [
{ label: '深色', value: 'dark' as const },
{ label: '浅色', value: 'light' as const },
];
async function checkUpdate() {
checkingUpdate.value = true;
updateMessage.value = '';
try {
const resp = await fetch('https://gitea.atdunbg.xyz/api/v1/repos/atdunbg/Nekosonic-Music/releases?limit=1&draft=false');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const releases = await resp.json();
if (releases && releases.length > 0) {
latestRelease.value = releases[0];
showUpdateModal.value = true;
} else {
updateMessage.value = '暂无发布版本';
updateMessageClass.value = 'text-content-3';
}
} catch (e: any) {
updateMessage.value = `获取失败: ${e}`;
updateMessageClass.value = 'text-danger';
} finally {
checkingUpdate.value = false;
}
}
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return dateStr;
}
}
const recordingId = ref<string | null>(null);
function formatShortcut(key: string): string {
return key
.replace('Control', 'Ctrl')
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace(/\+/g, ' + ');
}
function startRecording(id: string) {
recordingId.value = id;
}
function resetShortcuts() {
settings.resetShortcuts();
showToast('快捷键已恢复默认', 'success');
}
const showResetConfirm = ref(false);
function handleResetAll() {
showResetConfirm.value = true;
}
function confirmResetAll() {
settings.resetAll();
showResetConfirm.value = false;
showToast('已恢复默认设置', 'success');
}
function onRecordingKeydown(e: KeyboardEvent) {
if (!recordingId.value) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
recordingId.value = null;
return;
}
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push('Control');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
const ignoredKeys = ['Control', 'Alt', 'Shift', 'Meta'];
if (!ignoredKeys.includes(e.key)) {
parts.push(e.code);
}
if (parts.length > 0 && !ignoredKeys.includes(e.key)) {
const combo = parts.join('+');
settings.setShortcut(recordingId.value, combo);
recordingId.value = null;
}
}
onMounted(() => {
window.addEventListener('keydown', onRecordingKeydown, true);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onRecordingKeydown, true);
});
</script>