diff --git a/CHANGELOG.md b/CHANGELOG.md index 0259a37..ca556ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +## v0.5.0 + +### ✨ 新功能 +- **蓝牙耳机/键盘媒体键控制**:支持通过蓝牙耳机按钮、键盘媒体键、系统通知栏/锁屏面板控制播放、暂停、切歌(Windows / Linux / macOS) +- **网络状态检测**:断网和恢复时弹出提示,网络恢复后自动重新加载页面内容 +- **音量记忆**:关闭应用后音量设置不丢失,下次打开自动恢复 +- **歌词翻译**:支持显示歌词翻译,可在漫游页面切换开关 +- **登录页优化**:已登录用户访问登录页会自动跳转回首页 + +### 🎨 变更 +- 默认主题色改为天蓝色 +- 全局快捷键显示顺序调整为 Ctrl + Alt(之前是 Alt + Ctrl) +- 快捷键显示优化:按键名更简洁,如 KeyP 显示为 P +- 页面缓存优化:更多页面切换时保留状态,窗口隐藏时自动释放 +- 登录页等待确认时的文字颜色修正 + +### 🐛 修复 +- 手动检查更新时,之前跳过的版本现在会正常弹出更新提示 +- 点击正在播放的歌曲无法恢复播放的问题 +- 部分内部类型定义问题导致的潜在隐患 + +### ⚡ 底层优化 +- 音频播放引擎全面重构,播放更稳定 +- 后端 API 调用模式统一,代码更易维护 +- 歌曲数据模型统一,各页面显示更一致 + + ## v0.4.1 添加音频输出外设选择 diff --git a/README.md b/README.md index 18884a9..2c9242e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - 📻 私人漫游 FM(个性化推荐,VIP 试听自动跳过) - 🎵 本地音乐播放(支持 mp3 / flac / wav / ogg / aac / m4a / wma / opus) - 🔊 音频输出设备选择 +- 🎧 系统媒体控制(蓝牙耳机/键盘媒体键/系统面板,支持 Linux / Windows / macOS) ### 发现与浏览 @@ -27,6 +28,7 @@ ### 歌词与评论 - 🎤 实时滚动歌词(自动滚动 / 点击跳转 / 渐变透明度) +- 🎤 歌词翻译显示 - 🎤 全屏漫游模式(大封面 + 歌词 / 评论双标签页) - 💬 歌曲评论查看(热门评论 + 无限滚动加载 + 点赞) @@ -47,10 +49,11 @@ - 📡 系统托盘(播放控制 / 显示窗口 / 退出) - 🛡 单实例运行(防止重复启动) - ⌨️ 自定义快捷键(应用内 + 系统全局) -- 🌚 Light / Dark Mode 主题切换 +- 🎨 多主题切换(天蓝 / 翠绿 / 玫红 / 紫罗兰 / 橙色 / 青色 / 粉色) - ⚙️ 关闭窗口行为设置(每次询问 / 最小化到托盘 / 直接退出) - 🔄 自动更新(启动静默检测 + 自定义弹窗 + 忽略版本 + 下载进度) - 📝 更新日志查看 +- 📶 网络状态检测(断网/恢复 Toast 提示 + 自动重试加载) ## 📦️ 安装 @@ -84,7 +87,8 @@ npm run tauri build | 样式 | Tailwind CSS v4 + CSS 变量主题系统 | | 状态管理 | Pinia | | 路由 | Vue Router 4 | -| 音频播放 | rodio (Rust) | +| 音频解码 | symphonia + ringbuf (Rust) | +| 媒体控制 | souvlaki (Linux MPRIS / Windows SMTC / macOS Now Playing) | | 网易云 API | ncm-api-rs | | 构建工具 | Vite 6 | @@ -97,10 +101,11 @@ npm run tauri build - [x] 专辑详情页 - [x] 自定义全局快捷键 - [x] 自动更新 +- [x] 歌词翻译 +- [x] 更多主题 +- [x] 系统媒体控制(蓝牙耳机/键盘媒体键) - [ ] MV 播放 - [ ] 音乐云盘 -- [ ] 歌词翻译 -- [ ] 更多主题 - [ ] 桌面歌词 欢迎提 Issue 和 Pull request。 @@ -117,4 +122,5 @@ npm run tauri build - [Tauri](https://tauri.app/) — 跨平台桌面应用框架 - [Vue.js](https://vuejs.org/) — 渐进式 JavaScript 框架 - [Tailwind CSS](https://tailwindcss.com/) — 实用优先的 CSS 框架 -- [rodio](https://crates.io/crates/rodio) — Rust 音频播放库 +- [symphonia](https://crates.io/crates/symphonia) — Rust 纯音频解码库 +- [souvlaki](https://crates.io/crates/souvlaki) — 跨平台 OS 媒体控制库 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36549a6..dfb8b28 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "Nekosonic" -version = "0.4.1" +version = "0.5.0" dependencies = [ "base64 0.22.1", "cpal", @@ -12,10 +12,13 @@ dependencies = [ "futures-util", "lofty", "ncm-api-rs", + "raw-window-handle", "reqwest 0.12.28", - "rodio", + "ringbuf", "serde", "serde_json", + "souvlaki", + "symphonia", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -120,13 +123,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" +dependencies = [ + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-broadcast" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -152,12 +165,44 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.4.1", + "futures-lite 2.6.1", "pin-project-lite", "slab", ] +[[package]] +name = "async-fs" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "blocking", + "futures-lite 1.13.0", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + [[package]] name = "async-io" version = "2.6.0" @@ -168,25 +213,51 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "parking", - "polling", - "rustix", + "polling 3.11.0", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] +[[package]] +name = "async-process" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" +dependencies = [ + "async-io 1.13.0", + "async-lock 2.8.0", + "async-signal", + "blocking", + "cfg-if", + "event-listener 3.1.0", + "futures-lite 1.13.0", + "rustix 0.38.44", + "windows-sys 0.48.0", +] + [[package]] name = "async-process" version = "2.5.0" @@ -194,15 +265,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel", - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", - "futures-lite", - "rustix", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", ] [[package]] @@ -222,13 +293,13 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "atomic-waker", "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -352,6 +423,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -388,7 +465,7 @@ dependencies = [ "async-channel", "async-task", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "piper", ] @@ -612,10 +689,34 @@ dependencies = [ ] [[package]] -name = "claxon" -version = "0.4.3" +name = "cocoa" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] [[package]] name = "combine" @@ -671,6 +772,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -687,6 +798,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -694,9 +818,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", - "core-graphics-types", - "foreign-types", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", "libc", ] @@ -707,7 +842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -893,6 +1028,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + [[package]] name = "der" version = "0.7.10" @@ -914,6 +1058,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -999,6 +1154,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -1218,6 +1379,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1235,10 +1413,25 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.1", "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1260,7 +1453,7 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" dependencies = [ - "memoffset", + "memoffset 0.9.1", "rustc_version", ] @@ -1308,6 +1501,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1315,7 +1517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1329,6 +1531,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1377,13 +1585,28 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + [[package]] name = "futures-lite" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", + "fastrand 2.4.1", "futures-core", "futures-io", "parking", @@ -1544,7 +1767,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -1787,6 +2010,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1799,12 +2028,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "html5ever" version = "0.38.0" @@ -1907,7 +2130,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2104,6 +2327,26 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2321,17 +2564,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lewton" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" -dependencies = [ - "byteorder", - "ogg", - "tinyvec", -] - [[package]] name = "libappindicator" version = "0.9.0" @@ -2406,6 +2638,18 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2480,6 +2724,15 @@ dependencies = [ "libc", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2507,6 +2760,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2660,6 +2922,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", +] + [[package]] name = "nom" version = "7.1.3" @@ -2755,6 +3029,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2986,15 +3269,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ogg" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" -dependencies = [ - "byteorder", -] - [[package]] name = "ogg_pager" version = "0.7.1" @@ -3186,7 +3460,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand", + "fastrand 2.4.1", "phf_shared 0.13.1", ] @@ -3247,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.4.1", "futures-io", ] @@ -3317,6 +3591,22 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "polling" version = "3.11.0" @@ -3325,12 +3615,33 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.2", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3471,7 +3782,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -3508,7 +3819,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -3807,16 +4118,14 @@ dependencies = [ ] [[package]] -name = "rodio" -version = "0.20.1" +name = "ringbuf" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" dependencies = [ - "claxon", - "cpal", - "hound", - "lewton", - "symphonia", + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", ] [[package]] @@ -3854,6 +4163,33 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3863,7 +4199,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3909,7 +4245,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -4035,7 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4249,6 +4585,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4326,6 +4673,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.6.3" @@ -4384,6 +4741,27 @@ dependencies = [ "system-deps", ] +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64 0.22.1", + "block", + "cocoa", + "core-graphics 0.22.3", + "dbus", + "dbus-crossroads", + "dispatch", + "objc", + "pollster", + "thiserror 1.0.69", + "windows 0.44.0", + "zbus 3.15.2", + "zvariant 3.15.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -4406,6 +4784,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.9.0" @@ -4460,9 +4844,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", + "symphonia-bundle-flac", "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", "symphonia-core", "symphonia-metadata", + "symphonia-utils-xiph", ] [[package]] @@ -4477,6 +4882,48 @@ dependencies = [ "symphonia-metadata", ] +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-core" version = "0.5.5" @@ -4490,6 +4937,56 @@ dependencies = [ "log", ] +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "symphonia-metadata" version = "0.5.5" @@ -4502,6 +4999,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -4509,6 +5016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -4564,8 +5072,8 @@ checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", - "core-graphics", + "core-foundation 0.10.1", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", @@ -4829,7 +5337,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows 0.61.3", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -4854,7 +5362,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.15.0", ] [[package]] @@ -4996,10 +5504,10 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", + "fastrand 2.4.1", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5121,7 +5629,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -5402,7 +5910,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "windows-sys 0.61.2", ] @@ -5559,6 +6067,12 @@ dependencies = [ "libc", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -5893,6 +6407,15 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows" version = "0.54.0" @@ -6582,7 +7105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -6599,7 +7122,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", ] [[package]] @@ -6631,29 +7164,70 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" +dependencies = [ + "async-broadcast 0.5.1", + "async-executor", + "async-fs", + "async-io 1.13.0", + "async-lock 2.8.0", + "async-process 1.8.1", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "byteorder", + "derivative", + "enumflags2", + "event-listener 2.5.3", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "once_cell", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "winapi", + "xdg-home", + "zbus_macros 3.15.2", + "zbus_names 2.6.1", + "zvariant 3.15.2", +] + [[package]] name = "zbus" version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" dependencies = [ - "async-broadcast", + "async-broadcast 0.7.2", "async-executor", - "async-io", - "async-lock", - "async-process", + "async-io 2.6.0", + "async-lock 3.4.2", + "async-process 2.5.0", "async-recursion", "async-task", "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.4.1", "futures-core", - "futures-lite", + "futures-lite 2.6.1", "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", @@ -6661,9 +7235,23 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.2", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.15.0", + "zbus_names 4.3.2", + "zvariant 5.11.0", +] + +[[package]] +name = "zbus_macros" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "zvariant_utils 1.0.1", ] [[package]] @@ -6676,9 +7264,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.2", + "zvariant 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zbus_names" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" +dependencies = [ + "serde", + "static_assertions", + "zvariant 3.15.2", ] [[package]] @@ -6689,7 +7288,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.2", - "zvariant", + "zvariant 5.11.0", ] [[package]] @@ -6790,6 +7389,20 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zvariant" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" +dependencies = [ + "byteorder", + "enumflags2", + "libc", + "serde", + "static_assertions", + "zvariant_derive 3.15.2", +] + [[package]] name = "zvariant" version = "5.11.0" @@ -6800,8 +7413,21 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.2", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.11.0", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_derive" +version = "3.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", + "zvariant_utils 1.0.1", ] [[package]] @@ -6814,7 +7440,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.1", +] + +[[package]] +name = "zvariant_utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index decf5ca..600161c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Nekosonic" -version = "0.4.1" +version = "0.5.0" description = "A Simple music app" authors = ["atdunbg"] edition = "2021" @@ -23,7 +23,8 @@ tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-dialog = "2" -rodio = "0.20" +symphonia = { version = "0.5", features = ["mp3", "aac", "flac", "wav", "ogg", "vorbis", "isomp4", "mkv"] } +ringbuf = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" cpal = { version = "0.15" } @@ -38,3 +39,13 @@ tokio = { version = "1", features = ["rt", "sync"] } tauri-plugin-process = "2.3.1" tauri-plugin-updater = "2" +[target.'cfg(target_os = "linux")'.dependencies] +souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } + +[target.'cfg(target_os = "windows")'.dependencies] +souvlaki = "0.8" +raw-window-handle = "0.6" + +[target.'cfg(target_os = "macos")'.dependencies] +souvlaki = "0.8" + diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs index fb20552..e0e155f 100644 --- a/src-tauri/src/api.rs +++ b/src-tauri/src/api.rs @@ -2,7 +2,6 @@ use ncm_api_rs::{create_client, ApiClient, Query}; use serde::Deserialize; use serde_json::json; use tauri::{Manager, State, Emitter}; -use tokio::sync::Mutex; use std::sync::Mutex as StdMutex; use std::sync::atomic::Ordering; @@ -14,12 +13,66 @@ use lofty::file::{AudioFile, TaggedFileExt}; use lofty::tag::Accessor; use base64::Engine; +/// 统一的 API 调用宏,封装了「获取客户端 → 构建请求 → 发送 → 提取响应体」的完整流程。 +/// +/// 消除了每个 Tauri 命令中重复的 `client.lock().unwrap().clone()` + `build_query()` + `.map(|r| r.body.to_string())` 样板代码。 +/// +/// 提供三种调用方式: +/// +/// 1. 无额外参数 — 仅使用 cookie 中的默认参数构建请求 +/// ``` +/// api_call!(state, get_playlist_detail) +/// // 等价于: +/// // let client = state.client.lock().unwrap().clone(); +/// // let q = state.build_query(); +/// // client.get_playlist_detail(&q).await.map(|r| r.body.to_string()).map_err(|e| e.to_string()) +/// ``` +/// +/// 2. 附加参数 — 在默认参数基础上追加键值对 +/// ``` +/// api_call!(state, song_url_v1, params: [("id", id), ("level", "standard")]) +/// // 等价于: +/// // let q = state.build_query().param("id", id).param("level", "standard"); +/// // client.song_url_v1(&q).await... +/// ``` +/// +/// 3. 预构建查询 — 直接传入已构建好的 Query 对象,跳过 build_query() +/// ``` +/// api_call!(state, playlist_track_all, query: my_query) +/// // 等价于: +/// // client.playlist_track_all(&my_query).await... +/// ``` +macro_rules! api_call { + ($state:expr, $method:ident) => {{ + let client = $state.client.lock().unwrap().clone(); + let q = $state.build_query(); + client.$method(&q).await + .map(|r| r.body.to_string()) + .map_err(|e| e.to_string()) + }}; + ($state:expr, $method:ident, params: [$(($key:expr, $val:expr)),* $(,)?]) => {{ + let client = $state.client.lock().unwrap().clone(); + let mut q = $state.build_query(); + $(q = q.param($key, $val);)* + client.$method(&q).await + .map(|r| r.body.to_string()) + .map_err(|e| e.to_string()) + }}; + ($state:expr, $method:ident, query: $q:expr) => {{ + let client = $state.client.lock().unwrap().clone(); + client.$method(&$q).await + .map(|r| r.body.to_string()) + .map_err(|e| e.to_string()) + }}; +} + pub struct ApiController { - client: Mutex, + client: StdMutex, cookie: StdMutex>, cookie_path: PathBuf, } +/// 将 Cookie 字符串列表转换为 `key=value; key=value` 格式 fn cookies_to_key_values(cookies: &[String]) -> String { cookies .iter() @@ -31,6 +84,7 @@ fn cookies_to_key_values(cookies: &[String]) -> String { impl ApiController { + /// 创建新的 API 控制器,从本地文件恢复已保存的 Cookie 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"); @@ -40,13 +94,14 @@ impl ApiController { let client = create_client(None); ApiController { - client: Mutex::new(client), + client: StdMutex::new(client), cookie: StdMutex::new(saved_cookie), cookie_path, } } -fn build_query(&self) -> Query { + /// 构建带当前 Cookie 的 API 查询对象 + 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() { @@ -55,66 +110,57 @@ fn build_query(&self) -> Query { } query } + /// 将 Cookie 字符串持久化到本地文件并同步到 API 客户端 fn save_cookie(&self, cookie_str: &str) { let _ = fs::write(&self.cookie_path, cookie_str); + if let Ok(mut client) = self.client.lock() { + client.set_cookie(cookie_str.to_string()); + } } } +/// 搜索查询参数 #[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 { - let client = state.client.lock().await; - let q = state.build_query() - .param("keywords", &query.keyword) - .param("type", "1") - .param("limit", "30"); - client.cloudsearch(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, cloudsearch, params: [("keywords", &query.keyword), ("type", "1"), ("limit", "30")]) } /// 获取热搜词列表 #[tauri::command] pub async fn get_hot_search(state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query(); - client.search_hot_detail(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, search_hot_detail) } +/// 歌单全部曲目查询参数 #[derive(Deserialize)] pub struct PlaylistTrackAllQuery { pub id: u64, pub limit: Option, pub offset: Option } /// 获取歌单全部歌曲 #[tauri::command] pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query() - .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()) + api_call!(state, playlist_track_all, params: [("id", &query.id.to_string()), ("limit", &query.limit.unwrap_or(1000).to_string()), ("offset", &query.offset.unwrap_or(0).to_string())]) } +/// 歌曲播放地址查询参数 #[derive(Deserialize)] pub struct SongUrlQuery { pub id: u64, pub level: Option, pub fm_mode: Option } /// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等) #[tauri::command] pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; + let client = state.client.lock().unwrap().clone(); let level = query.level.as_deref().unwrap_or("standard"); let resp = if query.fm_mode.unwrap_or(false) { @@ -162,27 +208,19 @@ pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) /// 获取歌词 #[tauri::command] pub async fn get_lyric(id: u64, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query().param("id", &id.to_string()); - client.lyric(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, lyric, params: [("id", &id.to_string())]) } /// 获取歌单详情 #[tauri::command] pub async fn get_playlist_detail(id: u64, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query().param("id", &id.to_string()); - client.playlist_detail(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, playlist_detail, params: [("id", &id.to_string())]) } /// 手机号密码登录 #[tauri::command] pub async fn login(query: LoginQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; + let client = state.client.lock().unwrap().clone(); let q = Query::new() .param("phone", &query.phone) .param("password", &query.password); @@ -208,7 +246,7 @@ pub async fn logout(state: State<'_, ApiController>) -> Result<(), String> { /// 获取二维码登录密钥 #[tauri::command] pub async fn get_qr_key(state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; + let client = state.client.lock().unwrap().clone(); let q = state.build_query(); let resp = client.login_qr_key(&q).await.map_err(|e| e.to_string())?; resp.body["unikey"] @@ -223,7 +261,7 @@ pub async fn create_qr( query: QrKeyQuery, state: State<'_, ApiController>, ) -> Result { - let client = state.client.lock().await; + let client = state.client.lock().unwrap().clone(); let q = state .build_query() .param("key", &query.key) @@ -239,7 +277,7 @@ pub async fn create_qr( /// 检查二维码扫码状态 #[tauri::command] pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; + let client = state.client.lock().unwrap().clone(); let q = state.build_query().param("key", &query.key); let resp = client.login_qr_check(&q).await.map_err(|e| e.to_string())?; if resp.body["code"].as_u64() == Some(803) && !resp.cookie.is_empty() { @@ -253,122 +291,80 @@ pub async fn check_qr_status(query: QrKeyQuery, state: State<'_, ApiController>) /// 获取当前登录状态 #[tauri::command] pub async fn get_login_status(state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query(); - client.user_account(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, user_account) } /// 获取用户歌单列表 #[tauri::command] pub async fn user_playlist(uid: u64, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query().param("uid", &uid.to_string()); - let resp = client.user_playlist(&q).await.map_err(|e| e.to_string())?; - Ok(resp.body.to_string()) + api_call!(state, user_playlist, params: [("uid", &uid.to_string())]) } /// 获取每日推荐歌曲 #[tauri::command] pub async fn recommend_songs(state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query(); - let resp = client.recommend_songs(&q).await.map_err(|e| e.to_string())?; - Ok(resp.body.to_string()) + api_call!(state, recommend_songs) } /// 获取推荐歌单 #[tauri::command] pub async fn recommend_resource(state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query(); - let resp = client.recommend_resource(&q).await.map_err(|e| e.to_string())?; - Ok(resp.body.to_string()) + api_call!(state, recommend_resource) } /// 获取私人漫游歌曲 #[tauri::command] pub async fn personal_fm(state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query(); - let resp = client.personal_fm(&q).await.map_err(|e| e.to_string())?; - Ok(resp.body.to_string()) + api_call!(state, personal_fm) } /// 获取歌曲详情 #[tauri::command] pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - 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()) + api_call!(state, song_detail, params: [("ids", &id)]) } +/// 用户播放记录查询参数 #[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 { - 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()) + api_call!(state, likelist, params: [("uid", &uid.to_string())]) } /// 获取用户播放记录 #[tauri::command] pub async fn user_record(query: UserRecordQuery, state: State<'_, ApiController>) -> Result { - 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()) + api_call!(state, user_record, params: [("uid", &query.uid.to_string()), ("type", &query.r#type)]) } /// 喜欢/取消喜欢歌曲 #[tauri::command] pub async fn like_song(query: LikeSongQuery, state: State<'_, ApiController>) -> Result { - 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()) + api_call!(state, like, params: [("id", &query.id.to_string()), ("like", &query.like)]) } /// 上报最近播放歌曲 #[tauri::command] pub async fn record_recent_song(limit: u64, state: State<'_, ApiController>) -> Result { - 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()) + api_call!(state, record_recent_song, params: [("limit", &limit.to_string())]) } +/// 歌单收藏/取消收藏查询参数 #[derive(Deserialize)] pub struct PlaylistSubscribeQuery { pub id: u64, pub subscribe: Option } /// 收藏/取消收藏歌单 #[tauri::command] pub async fn playlist_subscribe(query: PlaylistSubscribeQuery, state: State<'_, ApiController>) -> Result { - 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()) + api_call!(state, playlist_subscribe, params: [("id", &query.id.to_string()), ("t", t)]) } /// 退出应用 @@ -380,6 +376,7 @@ pub async fn exit_app(app_handle: tauri::AppHandle) { } } +/// 本地歌曲信息结构体 #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalSongInfo { @@ -395,6 +392,7 @@ pub struct LocalSongInfo { pub local: bool, } +/// 下载歌曲查询参数 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadSongQuery { @@ -408,6 +406,7 @@ pub struct DownloadSongQuery { pub download_path: Option, } +/// 下载歌曲到本地,支持进度回调,并保存元数据文件 #[tauri::command] pub async fn download_song( app_handle: tauri::AppHandle, @@ -419,22 +418,18 @@ pub async fn download_song( let q = state.build_query() .param("id", &query.id.to_string()) .param("level", level); - let client = state.client.lock().await; + let client = state.client.lock().unwrap().clone(); 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()); + let is_vip = data.get("freeTrialInfo").is_some_and(|v| !v.is_null()); + if is_vip { + return Err("VIP歌曲无法下载".into()); + } 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); @@ -505,6 +500,7 @@ pub async fn download_song( Ok(filename) } +/// 列出本地已下载的歌曲,优先使用元数据文件补充信息 #[tauri::command] pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option) -> Result, String> { let download_dir = resolve_download_dir(&app_handle, download_path.as_deref()); @@ -600,6 +596,7 @@ pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option (String, String, String, u64, Option) { match lofty::read_from_path(path) { Ok(tagged_file) => { @@ -638,6 +635,7 @@ fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option (String, String) { if let Some(pos) = stem.find(" - ") { let artist = &stem[..pos]; @@ -652,6 +650,7 @@ fn parse_filename(stem: &str) -> (String, String) { } } +/// 删除本地歌曲查询参数 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeleteLocalSongQuery { @@ -660,6 +659,7 @@ pub struct DeleteLocalSongQuery { pub download_path: Option, } +/// 删除本地已下载的歌曲文件及其元数据 #[tauri::command] pub fn delete_local_song( app_handle: tauri::AppHandle, @@ -678,6 +678,7 @@ pub fn delete_local_song( Ok(()) } +/// 检查指定歌曲是否已下载到本地 #[tauri::command] pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option) -> Result { let download_dir = resolve_download_dir(&app_handle, download_path.as_deref()); @@ -685,6 +686,7 @@ pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Op 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() { @@ -694,6 +696,7 @@ fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str> get_default_download_dir(app_handle) } +/// 获取默认下载目录,优先使用应用数据目录下的 downloads 子目录 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"); @@ -708,11 +711,13 @@ fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf { 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| { @@ -729,18 +734,15 @@ fn sanitize_filename(name: &str) -> String { .to_string() } +/// 获取歌手详情 #[tauri::command] pub async fn artist_detail(id: u64, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query().param("id", &id.to_string()); - client.artist_detail(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, artist_detail, params: [("id", &id.to_string())]) } +/// 获取歌手歌曲列表 #[tauri::command] pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; let mut q = state.build_query().param("id", &query.id.to_string()); if let Some(ref order) = query.order { q = q.param("order", order); @@ -751,11 +753,10 @@ pub async fn artist_songs(query: ArtistSongsQuery, state: State<'_, ApiControlle if let Some(offset) = query.offset { q = q.param("offset", &offset.to_string()); } - client.artist_songs(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, artist_songs, query: q) } +/// 歌手歌曲查询参数 #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ArtistSongsQuery { @@ -765,9 +766,9 @@ pub struct ArtistSongsQuery { pub offset: Option, } +/// 获取歌手专辑列表 #[tauri::command] pub async fn artist_album(id: u64, limit: Option, offset: Option, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; let mut q = state.build_query().param("id", &id.to_string()); if let Some(limit) = limit { q = q.param("limit", &limit.to_string()); @@ -775,32 +776,24 @@ pub async fn artist_album(id: u64, limit: Option, offset: Option, stat if let Some(offset) = offset { q = q.param("offset", &offset.to_string()); } - client.artist_album(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, artist_album, query: q) } +/// 获取歌手简介 #[tauri::command] pub async fn artist_desc(id: u64, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query().param("id", &id.to_string()); - client.artist_desc(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, artist_desc, params: [("id", &id.to_string())]) } +/// 获取专辑详情 #[tauri::command] pub async fn album_detail(id: u64, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query().param("id", &id.to_string()); - client.album(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, album, params: [("id", &id.to_string())]) } +/// 获取最新评论 #[tauri::command] pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; let mut q = state.build_query() .param("type", &query.r#type.to_string()) .param("id", &query.id.to_string()); @@ -816,11 +809,10 @@ pub async fn comment_new(query: CommentNewQuery, state: State<'_, ApiController> if let Some(cursor) = query.cursor { q = q.param("cursor", &cursor.to_string()); } - client.comment_new(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, comment_new, query: q) } +/// 最新评论查询参数 #[derive(Deserialize)] pub struct CommentNewQuery { pub r#type: u8, @@ -834,9 +826,9 @@ pub struct CommentNewQuery { pub cursor: Option, } +/// 获取热门评论 #[tauri::command] pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; let mut q = state.build_query() .param("type", &query.r#type.to_string()) .param("id", &query.id.to_string()); @@ -849,11 +841,10 @@ pub async fn comment_hot(query: CommentHotQuery, state: State<'_, ApiController> if let Some(before) = query.before { q = q.param("before", &before.to_string()); } - client.comment_hot(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, comment_hot, query: q) } +/// 热门评论查询参数 #[derive(Deserialize)] pub struct CommentHotQuery { pub r#type: u8, @@ -863,9 +854,9 @@ pub struct CommentHotQuery { pub before: Option, } +/// 获取评论楼层(子评论) #[tauri::command] pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; let mut q = state.build_query() .param("parentCommentId", &query.parent_comment_id.to_string()) .param("type", &query.r#type.to_string()) @@ -876,11 +867,10 @@ pub async fn comment_floor(query: CommentFloorQuery, state: State<'_, ApiControl if let Some(time) = query.time { q = q.param("time", &time.to_string()); } - client.comment_floor(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, comment_floor, query: q) } +/// 评论楼层查询参数 #[derive(Deserialize)] pub struct CommentFloorQuery { #[serde(rename = "parentCommentId")] @@ -891,19 +881,13 @@ pub struct CommentFloorQuery { pub time: Option, } +/// 点赞/取消点赞评论 #[tauri::command] pub async fn comment_like(query: CommentLikeQuery, state: State<'_, ApiController>) -> Result { - let client = state.client.lock().await; - let q = state.build_query() - .param("t", &query.t.to_string()) - .param("type", &query.r#type.to_string()) - .param("id", &query.id.to_string()) - .param("cid", &query.cid.to_string()); - client.comment_like(&q).await - .map(|r| r.body.to_string()) - .map_err(|e| e.to_string()) + api_call!(state, comment_like, params: [("t", &query.t.to_string()), ("type", &query.r#type.to_string()), ("id", &query.id.to_string()), ("cid", &query.cid.to_string())]) } +/// 评论点赞查询参数 #[derive(Deserialize)] pub struct CommentLikeQuery { pub t: u8, diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index af3f580..548f687 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,14 +1,24 @@ -use rodio::{Decoder, OutputStream, Sink, Source}; -use rodio::cpal::traits::{DeviceTrait, HostTrait}; -use std::io::{Read, Seek, SeekFrom}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{SampleRate, Stream, StreamConfig}; +use ringbuf::{HeapCons, HeapProd, HeapRb, traits::{Split, Producer, Consumer}}; +use std::io::Read; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::Duration; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; +use symphonia::core::io::{MediaSource, MediaSourceStream}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; +use symphonia::core::units::Time; use tauri::AppHandle; use tauri::Emitter; -// ---------- 命令 ---------- +/// 音频控制命令枚举,用于音频线程之间的消息传递 enum AudioCmd { Play(String), PlayLocal(String), @@ -20,60 +30,71 @@ enum AudioCmd { SetDevice(Option), } +/// 音频控制器,通过通道向音频线程发送控制命令 pub struct AudioController { tx: Sender, current_url: Arc>>, + position: Arc>, } impl AudioController { + /// 创建新的音频控制器,并启动后台音频线程 pub fn new(app_handle: AppHandle) -> Self { let (tx, rx) = channel(); let current_url = Arc::new(Mutex::new(None)); + let position = Arc::new(Mutex::new(0.0)); let url_clone = current_url.clone(); + let pos_clone = position.clone(); let ah_clone = app_handle.clone(); - thread::spawn(move || audio_thread(rx, url_clone, ah_clone)); - AudioController { - tx, - current_url, - } + thread::spawn(move || audio_thread(rx, url_clone, pos_clone, ah_clone)); + AudioController { tx, current_url, position } } + /// 播放指定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); } + /// 设置音频输出设备,传入 None 则使用系统默认设备 pub fn set_device(&self, device: Option) { let _ = self.tx.send(AudioCmd::SetDevice(device)); } - pub fn seek(&self, time: f64) { - let _ = self.tx.send(AudioCmd::Seek(time)); - } - pub fn set_volume(&self, vol: f32) { - let _ = self.tx.send(AudioCmd::SetVolume(vol)); + /// 跳转到指定时间位置(秒) + pub fn seek(&self, time: f64) { let _ = self.tx.send(AudioCmd::Seek(time)); } + /// 设置播放音量,范围 0.0 ~ 1.0 + pub fn set_volume(&self, vol: f32) { let _ = self.tx.send(AudioCmd::SetVolume(vol)); } + /// 获取当前播放位置(秒) + pub fn get_position(&self) -> f64 { + *self.position.lock().unwrap() } } -// ---------- 流式缓冲区 ---------- - +/// 缓冲区内部状态,存储已下载的字节数据及完成/取消标志 struct BufferState { bytes: Vec, done: bool, cancelled: bool, } +/// 线程安全的共享缓冲区,支持生产者写入和消费者读取的同步等待 struct SharedBuffer { state: Mutex, available: Condvar, } impl SharedBuffer { + /// 创建新的空共享缓冲区 fn new() -> Self { SharedBuffer { state: Mutex::new(BufferState { @@ -85,43 +106,51 @@ impl SharedBuffer { } } + /// 向缓冲区追加写入一块数据,并通知等待的读取者 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 } } +/// 流式读取器,从共享缓冲区中按需读取数据,实现 `Read` 和 `Seek` trait struct StreamingReader { buffer: Arc, pos: usize, } impl StreamingReader { + /// 创建新的流式读取器,绑定到指定的共享缓冲区 fn new(buffer: Arc) -> Self { StreamingReader { buffer, pos: 0 } } @@ -144,33 +173,47 @@ impl Read for StreamingReader { 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(); + 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 { +impl std::io::Seek for StreamingReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { let new_pos = match pos { - SeekFrom::Start(offset) => offset as i64, - SeekFrom::Current(offset) => self.pos as i64 + offset, - SeekFrom::End(offset) => { + std::io::SeekFrom::Start(offset) => offset as i64, + std::io::SeekFrom::Current(offset) => self.pos as i64 + offset, + std::io::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")); + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )); } - let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap(); + 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")); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "seek before start", + )); } let mut state = self.buffer.state.lock().unwrap(); loop { @@ -179,47 +222,58 @@ impl Seek for StreamingReader { return Ok(self.pos as u64); } if state.done { - return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek past end")); + 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")); + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )); } - let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap(); + let result = self + .buffer + .available + .wait_timeout(state, Duration::from_millis(500)) + .unwrap(); state = result.0; } } } +impl MediaSource for StreamingReader { + fn is_seekable(&self) -> bool { true } + fn byte_len(&self) -> Option { None } +} + +/// 流式下载音频数据到共享缓冲区,支持下载进度事件通知 fn download_audio_streaming( url: &str, buffer: &SharedBuffer, app_handle: &AppHandle, ) -> Result<(), String> { - let resp = reqwest::blocking::get(url) - .map_err(|e| format!("下载失败: {}", e))?; - + 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 reader = resp; - loop { if buffer.is_cancelled() { return Err("下载已取消".to_string()); } - let mut chunk = [0u8; 8192]; - let read_size = reader.read(&mut chunk) + let read_size = reader + .read(&mut chunk) .map_err(|e| format!("读取失败: {}", e))?; if read_size == 0 { break; } buffer.write_chunk(&chunk[..read_size]); downloaded += read_size as u64; - let progress = if total_size > 0 { (downloaded as f64 / total_size as f64) * 100.0 } else { @@ -227,284 +281,540 @@ fn download_audio_streaming( }; let _ = app_handle.emit("cache-progress", progress); } - Ok(()) } +/// 初始缓冲区大小,达到此字节数后才开始播放 const INITIAL_BUFFER_SIZE: usize = 65536; +/// 环形缓冲区容量(采样数),约 4 秒的 48kHz 立体声数据 +const RING_BUFFER_SAMPLES: usize = 48000 * 4; -// ---------- 音频线程 ---------- -fn audio_thread(rx: Receiver, current_url: Arc>>, app_handle: AppHandle) { - let mut selected_device: Option = None; - let mut output = create_output(&selected_device); - let mut last_default_name = get_system_default_device_name(); +/// 播放状态,记录当前播放的运行时信息 +struct PlaybackState { + playing: Arc, + cancelled: Arc, + decode_done: Arc, + buffer_exhausted: Arc, + volume: Arc>, + sample_rate: u32, + channels: u16, + samples_played: Arc, + start_time: f64, +} - let mut current_volume: f32 = 1.0; - if let Some(ref sink) = output.sink { - sink.set_volume(current_volume); - } - - let mut current_audio_buffer: Option> = 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) => { - 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); - - 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); - } - } - 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 { - let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - sink.try_seek(Duration::from_secs_f64(time)) - })); - - match seek_res { - Ok(Ok(_)) => { /* 成功 */ } - Ok(Err(e)) => { - eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e); - if let Some(ref buffer) = current_audio_buffer { - sink.stop(); - sink.clear(); - 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 buffer) = current_audio_buffer { - sink.stop(); - sink.clear(); - 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), - } - } - } - } - } - } - - AudioCmd::SetVolume(vol) => { - current_volume = vol; - if let Some(ref sink) = output.sink { - sink.set_volume(vol); - } - } - - AudioCmd::SetDevice(dev) => { - selected_device = dev; - 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 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), - } - } - } - } - if selected_device.is_none() { - last_default_name = get_system_default_device_name(); - } - } - } - } - - 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 { - println!("[audio] 系统默认设备变化: {:?} -> {:?}", last_default_name, current_default); - last_default_name = current_default; - output = create_output(&selected_device); - if let Some(ref sink) = output.sink { - sink.set_volume(current_volume); - 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(); - }); - } - } - } - } - } - Err(_) => break, - } +impl PlaybackState { + /// 根据已播放采样数计算当前播放位置(秒) + fn position(&self) -> f64 { + let samples = self.samples_played.load(Ordering::Relaxed) as f64; + self.start_time + samples / (self.sample_rate as f64 * self.channels as f64) } } -// ---------- 其余函数保持不变 ---------- +/// 输出上下文,持有音频输出流和解码线程的句柄 +struct OutputContext { + _stream: Stream, + _decode_thread: thread::JoinHandle<()>, + playback: PlaybackState, +} +/// 将 Symphonia 解码后的音频缓冲区转换为交错排列的 f32 采样数据 +fn convert_to_interleaved_f32(decoded: &AudioBufferRef) -> Vec { + let channels = decoded.spec().channels.count(); + let frames = decoded.frames(); + let mut out = Vec::with_capacity(frames * channels); + + match decoded { + AudioBufferRef::U8(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / u8::MAX as f32 * 2.0 - 1.0); + } + } + } + AudioBufferRef::U16(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / u16::MAX as f32 * 2.0 - 1.0); + } + } + } + AudioBufferRef::U24(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0); + } + } + } + AudioBufferRef::U32(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / u32::MAX as f32 * 2.0 - 1.0); + } + } + } + AudioBufferRef::S8(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / i8::MAX as f32); + } + } + } + AudioBufferRef::S16(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / i16::MAX as f32); + } + } + } + AudioBufferRef::S24(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame].0 as f32 / 8388607.0); + } + } + } + AudioBufferRef::S32(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32 / i32::MAX as f32); + } + } + } + AudioBufferRef::F32(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame]); + } + } + } + AudioBufferRef::F64(buf) => { + for frame in 0..frames { + for ch in 0..channels { + out.push(buf.chan(ch)[frame] as f32); + } + } + } + } + + out +} + +/// 重混声道数,将交错采样数据从源声道数转换为目标声道数 +fn remix_channels( + interleaved: &[f32], + src_channels: u16, + target_channels: u16, + src_frames: usize, +) -> Vec { + if src_channels == target_channels { + return interleaved.to_vec(); + } + + let src_ch = src_channels as usize; + let tgt_ch = target_channels as usize; + let mut out = Vec::with_capacity(src_frames * tgt_ch); + + if src_ch == 1 && tgt_ch == 2 { + for &s in interleaved { + out.push(s); + out.push(s); + } + } else if src_ch == 2 && tgt_ch == 1 { + for i in 0..src_frames { + let l = interleaved[i * 2]; + let r = interleaved[i * 2 + 1]; + out.push((l + r) * 0.5); + } + } else { + for i in 0..src_frames { + for ch in 0..tgt_ch { + let src_ch_idx = ch.min(src_ch.saturating_sub(1)); + out.push(interleaved[i * src_ch + src_ch_idx]); + } + } + } + + out +} + +/// 对解码音频进行重采样和声道重混,输出目标采样率和声道数的交错 f32 数据 +fn resample_and_remix( + decoded: &AudioBufferRef, + target_sample_rate: u32, + target_channels: u16, + src_rate: f64, + src_channels: u16, +) -> Vec { + let interleaved = convert_to_interleaved_f32(decoded); + let src_frames = if src_channels > 0 { + interleaved.len() / src_channels as usize + } else { + 0 + }; + + if src_frames == 0 { + return Vec::new(); + } + + let remixed = remix_channels(&interleaved, src_channels, target_channels, src_frames); + let remixed_ch = target_channels as usize; + + let ratio = target_sample_rate as f64 / src_rate; + let need_resample = (ratio - 1.0).abs() > 0.001; + + if !need_resample { + return remixed; + } + + let target_frames = (src_frames as f64 * ratio).round() as usize; + if target_frames == 0 { + return Vec::new(); + } + + let mut out = Vec::with_capacity(target_frames * remixed_ch); + for i in 0..target_frames { + let src_pos = i as f64 / ratio; + let src_idx = src_pos as usize; + let frac = src_pos - src_idx as f64; + let next_idx = (src_idx + 1).min(src_frames - 1); + + for ch in 0..remixed_ch { + let s0 = remixed[src_idx * remixed_ch + ch]; + let s1 = remixed[next_idx * remixed_ch + ch]; + out.push(s0 + (s1 - s0) * frac as f32); + } + } + + out +} + +/// 将音频数据解码并写入环形缓冲区,供播放回调消费 +fn decode_to_ring( + mss: MediaSourceStream, + mut producer: HeapProd, + playing: Arc, + cancelled: Arc, + decode_done: Arc, + seek_time: Option, + target_sample_rate: u32, + target_channels: u16, +) { + let hint = Hint::new(); + let format_opts = FormatOptions { + enable_gapless: true, + ..Default::default() + }; + let metadata_opts = MetadataOptions::default(); + let decoder_opts = DecoderOptions::default(); + + let probed = match symphonia::default::get_probe().format(&hint, mss, &format_opts, &metadata_opts) { + Ok(p) => p, + Err(e) => { + eprintln!("[audio] 探测格式失败: {}", e); + decode_done.store(true, Ordering::Relaxed); + return; + } + }; + + let mut format_reader = probed.format; + let track = match format_reader + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + { + Some(t) => t, + None => { + eprintln!("[audio] 未找到有效音频轨道"); + decode_done.store(true, Ordering::Relaxed); + return; + } + }; + + let track_id = track.id; + let codec_params = &track.codec_params; + let src_rate = codec_params.sample_rate.unwrap_or(44100) as f64; + let src_channels = codec_params.channels.unwrap_or_else(|| { + symphonia::core::audio::Channels::FRONT_LEFT | symphonia::core::audio::Channels::FRONT_RIGHT + }).count() as u16; + + let mut decoder = match symphonia::default::get_codecs().make(codec_params, &decoder_opts) { + Ok(d) => d, + Err(e) => { + eprintln!("[audio] 创建解码器失败: {}", e); + decode_done.store(true, Ordering::Relaxed); + return; + } + }; + + if let Some(time) = seek_time { + let seek_to = SeekTo::Time { + time: Time::from(time), + track_id: Some(track_id), + }; + let _ = format_reader.seek(SeekMode::Accurate, seek_to); + } + + let ratio = target_sample_rate as f64 / src_rate; + let need_resample = (ratio - 1.0).abs() > 0.001; + let need_remix = src_channels != target_channels; + + while !cancelled.load(Ordering::Relaxed) { + let packet = match format_reader.next_packet() { + Ok(p) => p, + Err(SymphoniaError::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + break; + } + Err(SymphoniaError::ResetRequired) => continue, + Err(e) => { + eprintln!("[audio] 读取包失败: {}", e); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(e) => { + eprintln!("[audio] 解码失败: {}", e); + continue; + } + }; + + let samples = if need_resample || need_remix { + resample_and_remix(&decoded, target_sample_rate, target_channels, src_rate, src_channels) + } else { + convert_to_interleaved_f32(&decoded) + }; + + let mut write_pos = 0; + while write_pos < samples.len() && !cancelled.load(Ordering::Relaxed) { + let remaining = &samples[write_pos..]; + let n = producer.push_slice(remaining); + if n == 0 { + if !playing.load(Ordering::Relaxed) { + while !playing.load(Ordering::Relaxed) && !cancelled.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(10)); + } + } + thread::sleep(Duration::from_millis(1)); + continue; + } + write_pos += n; + } + } + + decode_done.store(true, Ordering::Relaxed); +} + +/// 启动音频播放,创建解码线程和 cpal 输出流 +fn start_playback( + mss: MediaSourceStream, + device: &cpal::Device, + current_volume: f32, + seek_time: Option, +) -> Result { + let default_config = device + .default_output_config() + .map_err(|e| format!("获取设备配置失败: {}", e))?; + + let sr = default_config.sample_rate().0; + let ch = default_config.channels(); + let sample_format = default_config.sample_format(); + + let rb = HeapRb::::new(RING_BUFFER_SAMPLES); + let (producer, consumer) = rb.split(); + + let playing = Arc::new(AtomicBool::new(true)); + let cancelled = Arc::new(AtomicBool::new(false)); + let decode_done = Arc::new(AtomicBool::new(false)); + let buffer_exhausted = Arc::new(AtomicBool::new(false)); + let volume = Arc::new(Mutex::new(current_volume)); + let samples_played = Arc::new(AtomicU64::new(0)); + let start_time = seek_time.unwrap_or(0.0); + + let playing_clone = playing.clone(); + let cancelled_clone = cancelled.clone(); + let decode_done_clone = decode_done.clone(); + let decode_handle = thread::spawn(move || { + decode_to_ring( + mss, + producer, + playing_clone, + cancelled_clone, + decode_done_clone, + seek_time, + sr, + ch, + ); + }); + + let stream = build_cpal_stream(device, sr, ch, sample_format, consumer, volume.clone(), playing.clone(), samples_played.clone(), decode_done.clone(), buffer_exhausted.clone())?; + stream.play().map_err(|e| format!("播放流失败: {}", e))?; + + Ok(OutputContext { + _stream: stream, + _decode_thread: decode_handle, + playback: PlaybackState { + playing, + cancelled, + decode_done, + buffer_exhausted, + volume, + sample_rate: sr, + channels: ch, + samples_played, + start_time, + }, + }) +} + +/// 构建 cpal 音频输出流,支持 f32、i16、u16 三种采样格式 +fn build_cpal_stream( + device: &cpal::Device, + sample_rate: u32, + channels: u16, + sample_format: cpal::SampleFormat, + mut consumer: HeapCons, + volume: Arc>, + playing: Arc, + samples_played: Arc, + decode_done: Arc, + buffer_exhausted: Arc, +) -> Result { + let config = StreamConfig { + channels, + sample_rate: SampleRate(sample_rate), + buffer_size: cpal::BufferSize::Default, + }; + + let err_fn = |err: cpal::StreamError| eprintln!("[audio] 输出错误: {}", err); + + match sample_format { + cpal::SampleFormat::F32 => { + let sp = samples_played; + let dd = decode_done; + let be = buffer_exhausted; + device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + if !playing.load(Ordering::Relaxed) { + data.fill(0.0); + return; + } + let vol = *volume.lock().unwrap(); + let read = consumer.pop_slice(data); + for (i, s) in data.iter_mut().enumerate() { + if i < read { + *s *= vol; + } else { + *s = 0.0; + } + } + sp.fetch_add(read as u64, Ordering::Relaxed); + if read == 0 && dd.load(Ordering::Relaxed) { + be.store(true, Ordering::Relaxed); + } + }, + err_fn, + None, + ) + .map_err(|e| format!("创建输出流失败: {}", e)) + } + + cpal::SampleFormat::I16 => { + let mut f32_buf: Vec = Vec::new(); + let sp = samples_played; + let dd = decode_done; + let be = buffer_exhausted; + device + .build_output_stream( + &config, + move |data: &mut [i16], _: &cpal::OutputCallbackInfo| { + if !playing.load(Ordering::Relaxed) { + data.fill(0); + return; + } + let vol = *volume.lock().unwrap(); + if f32_buf.len() != data.len() { + f32_buf.resize(data.len(), 0.0); + } + let read = consumer.pop_slice(&mut f32_buf); + for (i, s) in data.iter_mut().enumerate() { + if i < read { + *s = (f32_buf[i] * vol * 32767.0) + .clamp(-32768.0, 32767.0) as i16; + } else { + *s = 0; + } + } + sp.fetch_add(read as u64, Ordering::Relaxed); + if read == 0 && dd.load(Ordering::Relaxed) { + be.store(true, Ordering::Relaxed); + } + }, + err_fn, + None, + ) + .map_err(|e| format!("创建输出流失败: {}", e)) + } + + cpal::SampleFormat::U16 => { + let mut f32_buf: Vec = Vec::new(); + let sp = samples_played; + let dd = decode_done; + let be = buffer_exhausted; + device + .build_output_stream( + &config, + move |data: &mut [u16], _: &cpal::OutputCallbackInfo| { + if !playing.load(Ordering::Relaxed) { + data.fill(32768); + return; + } + let vol = *volume.lock().unwrap(); + if f32_buf.len() != data.len() { + f32_buf.resize(data.len(), 0.0); + } + let read = consumer.pop_slice(&mut f32_buf); + for (i, s) in data.iter_mut().enumerate() { + if i < read { + *s = ((f32_buf[i] * vol + 1.0) * 32767.5) + .clamp(0.0, 65535.0) as u16; + } else { + *s = 32768; + } + } + sp.fetch_add(read as u64, Ordering::Relaxed); + if read == 0 && dd.load(Ordering::Relaxed) { + be.store(true, Ordering::Relaxed); + } + }, + err_fn, + None, + ) + .map_err(|e| format!("创建输出流失败: {}", e)) + } + + _ => Err(format!("不支持的采样格式: {:?}", sample_format)), + } +} + +/// 获取系统默认输出设备的名称 fn get_system_default_device_name() -> Option { - rodio::cpal::default_host() + cpal::default_host() .default_output_device() .and_then(|d| d.name().ok()) } +/// 列出系统中所有可用的音频输出设备名称(去重排序后) pub fn list_output_devices() -> Vec { - let host = rodio::cpal::default_host(); + let host = cpal::default_host(); if let Ok(devices) = host.output_devices() { let mut names: Vec = devices.filter_map(|d| d.name().ok()).collect(); names.sort(); @@ -515,8 +825,9 @@ pub fn list_output_devices() -> Vec { } } -fn find_device_by_name(name: &str) -> Option { - let host = rodio::cpal::default_host(); +/// 按名称查找音频输出设备,未找到则返回 None +fn find_device_by_name(name: &str) -> Option { + let host = cpal::default_host(); if let Ok(devices) = host.output_devices() { for d in devices { if let Ok(n) = d.name() { @@ -529,64 +840,308 @@ fn find_device_by_name(name: &str) -> Option { None } -struct Output { - _stream: OutputStream, - sink: Option, +/// 获取音频输出设备,优先使用指定名称的设备,否则回退到系统默认设备 +fn get_output_device(selected: &Option) -> cpal::Device { + match selected { + Some(name) => find_device_by_name(name).unwrap_or_else(|| { + eprintln!("[audio] 未找到设备 `{}`,回退默认", name); + cpal::default_host() + .default_output_device() + .expect("无可用音频设备") + }), + None => cpal::default_host() + .default_output_device() + .expect("无可用音频设备"), + } } -fn create_output(selected_device: &Option) -> Output { - match selected_device { - Some(dev_name) => { - if let Some(dev) = find_device_by_name(dev_name) { - println!("[audio] 使用指定设备: {}", dev_name); - match OutputStream::try_from_device(&dev) { - Ok((stream, handle)) => { - match Sink::try_new(&handle) { - Ok(sink) => Output { _stream: stream, sink: Some(sink) }, - Err(e) => { - eprintln!("[audio] Sink 创建失败: {}", e); - Output { _stream: stream, sink: None } +/// 停止播放,取消解码并重置共享播放位置 +fn stop_playback(output_ctx: &mut Option, shared_position: &Arc>) { + if let Some(ref mut ctx) = output_ctx { + ctx.playback.cancelled.store(true, Ordering::Relaxed); + ctx.playback.playing.store(false, Ordering::Relaxed); + } + *output_ctx = None; + *shared_position.lock().unwrap() = 0.0; +} + +fn rebuild_mss( + local_path: &Option, + audio_buffer: &Option>, +) -> Option { + if let Some(ref path) = local_path { + let file = std::fs::File::open(path).ok()?; + Some(MediaSourceStream::new(Box::new(file), Default::default())) + } else if let Some(ref buffer) = audio_buffer { + let reader = StreamingReader::new(buffer.clone()); + Some(MediaSourceStream::new(Box::new(reader), Default::default())) + } else { + None + } +} + +fn restart_playback_on_device_change( + output_ctx: &mut Option, + shared_position: &Arc>, + local_path: &Option, + audio_buffer: &Option>, + selected_device: &Option, + current_volume: f32, + audio_paused: bool, +) -> Result { + let current_pos = output_ctx + .as_ref() + .map(|ctx| ctx.playback.position()) + .unwrap_or(0.0); + let was_paused = audio_paused; + stop_playback(output_ctx, shared_position); + + let mss = rebuild_mss(local_path, audio_buffer) + .ok_or_else(|| "无法重建音频源".to_string())?; + let device = get_output_device(selected_device); + + let ctx = start_playback(mss, &device, current_volume, Some(current_pos))?; + if was_paused { + ctx.playback.playing.store(false, Ordering::Relaxed); + } + Ok(ctx) +} + +/// 音频线程主循环,接收命令并管理播放生命周期,包括设备热切换和播放结束检测 +fn audio_thread(rx: Receiver, _current_url: Arc>>, shared_position: Arc>, app_handle: AppHandle) { + let mut selected_device: Option = None; + let mut current_volume: f32 = 1.0; + let mut output_ctx: Option = None; + let mut current_audio_buffer: Option> = None; + let mut current_local_path: Option = None; + let mut audio_active = false; + let mut audio_paused = false; + let mut manual_stop = false; + let mut last_default_name = get_system_default_device_name(); + + loop { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(cmd) => match cmd { + AudioCmd::Play(url) => { + audio_active = false; + audio_paused = false; + manual_stop = false; + current_local_path = None; + + stop_playback(&mut output_ctx, &shared_position); + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + + 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); + } + } + buffer_clone.mark_done(); + }); + + loop { + let len = buffer.len(); + if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() { + break; + } + thread::sleep(Duration::from_millis(50)); + } + + if buffer.is_cancelled() || buffer.len() == 0 { + current_audio_buffer = None; + continue; + } + + let mss = MediaSourceStream::new( + Box::new(StreamingReader::new(buffer.clone())), + Default::default(), + ); + + let device = get_output_device(&selected_device); + match start_playback(mss, &device, current_volume, None) { + Ok(ctx) => { + output_ctx = Some(ctx); + audio_active = true; + let _ = app_handle.emit("audio-started", ()); + } + Err(e) => { + eprintln!("[audio] 播放启动失败: {}", e); + } + } + } + + AudioCmd::PlayLocal(path) => { + audio_active = false; + audio_paused = false; + manual_stop = false; + current_local_path = Some(path.clone()); + + stop_playback(&mut output_ctx, &shared_position); + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + eprintln!("[audio] 打开本地文件失败: {}", e); + continue; + } + }; + + let buffer = Arc::new(SharedBuffer::new()); + current_audio_buffer = Some(buffer.clone()); + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let device = get_output_device(&selected_device); + match start_playback(mss, &device, current_volume, None) { + Ok(ctx) => { + output_ctx = Some(ctx); + audio_active = true; + let _ = app_handle.emit("audio-started", ()); + } + Err(e) => { + eprintln!("[audio] 本地播放失败: {}", e); + } + } + } + + AudioCmd::Pause => { + audio_paused = true; + if let Some(ref ctx) = output_ctx { + ctx.playback.playing.store(false, Ordering::Relaxed); + } + } + + AudioCmd::Resume => { + audio_paused = false; + if let Some(ref ctx) = output_ctx { + ctx.playback.playing.store(true, Ordering::Relaxed); + } + } + + AudioCmd::Stop => { + audio_active = false; + audio_paused = false; + manual_stop = true; + stop_playback(&mut output_ctx, &shared_position); + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + } + + AudioCmd::Seek(time) => { + stop_playback(&mut output_ctx, &shared_position); + + let mss = match rebuild_mss(¤t_local_path, ¤t_audio_buffer) { + Some(mss) => mss, + None => continue, + }; + + let device = get_output_device(&selected_device); + match start_playback(mss, &device, current_volume, Some(time)) { + Ok(ctx) => { + output_ctx = Some(ctx); + audio_active = true; + audio_paused = false; + } + Err(e) => { + eprintln!("[audio] seek 播放失败: {}", e); + } + } + } + + AudioCmd::SetVolume(vol) => { + current_volume = vol; + if let Some(ref ctx) = output_ctx { + *ctx.playback.volume.lock().unwrap() = vol; + } + } + + AudioCmd::SetDevice(dev) => { + selected_device = dev; + if audio_active { + match restart_playback_on_device_change( + &mut output_ctx, + &shared_position, + ¤t_local_path, + ¤t_audio_buffer, + &selected_device, + current_volume, + audio_paused, + ) { + Ok(ctx) => { output_ctx = Some(ctx); } + Err(e) => { eprintln!("[audio] 设备切换失败: {}", e); } + } + } + if selected_device.is_none() { + last_default_name = get_system_default_device_name(); + } + } + }, + + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if audio_active { + if let Some(ref ctx) = output_ctx { + if ctx.playback.decode_done.load(Ordering::Relaxed) + && ctx.playback.buffer_exhausted.load(Ordering::Relaxed) + && !manual_stop && !audio_paused { + audio_active = false; + let _ = app_handle.emit("audio-ended", ()); + } + let pos = ctx.playback.position(); + *shared_position.lock().unwrap() = pos; + } + } + + if selected_device.is_none() { + let current_default = get_system_default_device_name(); + if current_default != last_default_name { + println!( + "[audio] 系统默认设备变化: {:?} -> {:?}", + last_default_name, current_default + ); + last_default_name = current_default; + + if audio_active { + if let Ok(ctx) = restart_playback_on_device_change( + &mut output_ctx, + &shared_position, + ¤t_local_path, + ¤t_audio_buffer, + &selected_device, + current_volume, + audio_paused, + ) { + output_ctx = Some(ctx); } } } - Err(e) => { - eprintln!("[audio] 指定设备无效,回退默认: {}", e); - create_default_output() - } } - } else { - eprintln!("[audio] 未找到设备 `{}`,回退默认", dev_name); - create_default_output() } - } - None => { - println!("[audio] 跟随系统默认设备"); - create_default_output() + + Err(_) => break, } } } -fn create_default_output() -> Output { - match OutputStream::try_default() { - Ok((stream, handle)) => { - match Sink::try_new(&handle) { - Ok(sink) => Output { _stream: stream, sink: Some(sink) }, - Err(e) => { - eprintln!("[audio] 默认 Sink 失败: {}", e); - Output { _stream: stream, sink: None } - } - } - } - Err(e) => panic!("无法创建默认音频输出: {}", e), - } -} - -// ===================== Tauri 命令 ===================== use tauri::State; use std::sync::Mutex as StdMutex; +/// Tauri 管理的音频状态,内部包装 `AudioController` 的互斥锁 pub struct AppAudio(pub StdMutex); +/// Tauri 命令:播放网络音频 #[tauri::command] pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> { let ctrl = state.0.lock().map_err(|e| e.to_string())?; @@ -594,6 +1149,7 @@ pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> Ok(()) } +/// Tauri 命令:播放本地音频文件 #[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())?; @@ -601,43 +1157,50 @@ pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), Ok(()) } +/// Tauri 命令:暂停当前播放 #[tauri::command] pub fn pause_audio(state: State<'_, AppAudio>) { if let Ok(ctrl) = state.0.lock() { ctrl.pause(); } } +/// Tauri 命令:恢复播放 #[tauri::command] pub fn resume_audio(state: State<'_, AppAudio>) { if let Ok(ctrl) = state.0.lock() { ctrl.resume(); } } +/// Tauri 命令:停止当前播放 #[tauri::command] pub fn stop_audio(state: State<'_, AppAudio>) { if let Ok(ctrl) = state.0.lock() { ctrl.stop(); } } +/// Tauri 命令:获取所有可用的音频输出设备列表 #[tauri::command] pub fn get_output_devices() -> Vec { list_output_devices() } +/// Tauri 命令:设置音频输出设备,传入 None 使用系统默认设备 #[tauri::command] pub fn set_output_device(state: State<'_, AppAudio>, device: Option) { - if let Ok(ctrl) = state.0.lock() { - ctrl.set_device(device); - } + if let Ok(ctrl) = state.0.lock() { ctrl.set_device(device); } } +/// Tauri 命令:跳转到指定播放位置(秒) #[tauri::command] pub fn seek_audio(state: State<'_, AppAudio>, time: f64) { - if let Ok(ctrl) = state.0.lock() { - ctrl.seek(time); - } + if let Ok(ctrl) = state.0.lock() { ctrl.seek(time); } } +/// Tauri 命令:获取当前播放位置(秒) +#[tauri::command] +pub fn get_audio_position(state: State<'_, AppAudio>) -> f64 { + if let Ok(ctrl) = state.0.lock() { ctrl.get_position() } else { 0.0 } +} + +/// Tauri 命令:设置播放音量 #[tauri::command] pub fn set_volume(state: State<'_, AppAudio>, vol: f32) { - if let Ok(ctrl) = state.0.lock() { - ctrl.set_volume(vol); - } + if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9461ae4..735922c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; mod api; mod audio; +mod media_controls; use api::ApiController; use audio::AppAudio; @@ -25,6 +26,27 @@ pub fn run() { let app_audio = AppAudio(std::sync::Mutex::new(audio_controller)); app.manage(app_audio); + #[cfg(target_os = "windows")] + { + use raw_window_handle::HasWindowHandle; + use raw_window_handle::RawWindowHandle; + let hwnd = if let Some(win) = app.get_webview_window("main") { + win.window_handle().ok().and_then(|h| { + if let RawWindowHandle::Win32(h) = h.as_raw() { + Some(h.hwnd.get() as *mut std::ffi::c_void) + } else { + None + } + }) + } else { + None + }; + media_controls::start_media_controls(app.handle().clone(), hwnd); + } + + #[cfg(not(target_os = "windows"))] + media_controls::start_media_controls(app.handle().clone(), None); + let show = MenuItemBuilder::with_id("show", "显示窗口").build(app)?; let _sep1 = PredefinedMenuItem::separator(app)?; let prev = MenuItemBuilder::with_id("prev", "上一首").build(app)?; @@ -144,6 +166,7 @@ pub fn run() { audio::get_output_devices, audio::set_output_device, audio::seek_audio, + audio::get_audio_position, audio::set_volume, api::download_song, diff --git a/src-tauri/src/media_controls.rs b/src-tauri/src/media_controls.rs new file mode 100644 index 0000000..22f6f6f --- /dev/null +++ b/src-tauri/src/media_controls.rs @@ -0,0 +1,121 @@ +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, Listener}; +use souvlaki::{ + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, + PlatformConfig, SeekDirection, +}; + +struct MediaState { + controls: MediaControls, +} + +pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c_void>) { + let config = PlatformConfig { + dbus_name: "nekosonic", + display_name: "Nekosonic", + hwnd, + }; + + let mut controls = match MediaControls::new(config) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to create media controls: {e}"); + return; + } + }; + + let ah = app_handle.clone(); + if let Err(e) = controls.attach(move |event: MediaControlEvent| { + let cmd = match &event { + MediaControlEvent::Play => "Play", + MediaControlEvent::Pause => "Pause", + MediaControlEvent::Toggle => "PlayPause", + MediaControlEvent::Next => "Next", + MediaControlEvent::Previous => "Previous", + MediaControlEvent::Stop => "Stop", + MediaControlEvent::Raise => "Raise", + MediaControlEvent::Quit => "Quit", + MediaControlEvent::SetVolume(v) => { + let _ = ah.emit("mpris-command", format!("SetVolume:{v}")); + return; + } + MediaControlEvent::Seek(dir) => { + let offset_us = match dir { + SeekDirection::Forward => 5_000_000i64, + SeekDirection::Backward => -5_000_000i64, + }; + let _ = ah.emit("mpris-command", format!("Seek:{offset_us}")); + return; + } + MediaControlEvent::SeekBy(dir, duration) => { + let offset_us: i64 = match dir { + SeekDirection::Forward => duration.as_micros() as i64, + SeekDirection::Backward => -(duration.as_micros() as i64), + }; + let _ = ah.emit("mpris-command", format!("Seek:{offset_us}")); + return; + } + MediaControlEvent::SetPosition(pos) => { + let pos_us = pos.0.as_micros() as i64; + let _ = ah.emit("mpris-command", format!("SetPosition:{pos_us}")); + return; + } + MediaControlEvent::OpenUri(_) => return, + }; + let _ = ah.emit("mpris-command", cmd); + }) { + eprintln!("Failed to attach media control handler: {e}"); + return; + } + + let state = Arc::new(Mutex::new(MediaState { controls })); + let state_for_listener = state.clone(); + + app_handle.listen("playback-state", move |event| { + if let Ok(data) = serde_json::from_str::(event.payload()) { + let mut s = match state_for_listener.lock() { + Ok(s) => s, + Err(_) => return, + }; + + if let Some(status) = data.get("status").and_then(|v| v.as_str()) { + let playback = match status { + "playing" => MediaPlayback::Playing { progress: None }, + "paused" => MediaPlayback::Paused { progress: None }, + _ => MediaPlayback::Stopped, + }; + let _ = s.controls.set_playback(playback); + } + + let title = data.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let album = data.get("album").and_then(|v| v.as_str()).unwrap_or(""); + let artists = data + .get("artists") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a.as_str().map(|s| s.to_owned())) + .collect::>() + }) + .unwrap_or_default(); + let artist_str = artists.join(", "); + let cover_url = data.get("coverUrl").and_then(|v| v.as_str()).unwrap_or(""); + let duration_us = data.get("durationUs").and_then(|v| v.as_i64()).unwrap_or(0); + + let metadata = MediaMetadata { + title: if title.is_empty() { None } else { Some(title) }, + album: if album.is_empty() { None } else { Some(album) }, + artist: if artist_str.is_empty() { None } else { Some(&artist_str) }, + cover_url: if cover_url.is_empty() { None } else { Some(cover_url) }, + duration: if duration_us > 0 { + Some(std::time::Duration::from_micros(duration_us as u64)) + } else { + None + }, + }; + let _ = s.controls.set_metadata(metadata); + } + }); + + std::mem::forget(state); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1b68b80..24c021c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Nekosonic", - "version": "0.4.1", + "version": "0.5.0", "identifier": "com.atdunbg.Nekosonic", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.vue b/src/App.vue index 2ed9f46..2ed3561 100644 --- a/src/App.vue +++ b/src/App.vue @@ -124,7 +124,7 @@
- + @@ -281,6 +281,9 @@ import ToastContainer from './components/ToastContainer.vue'; import CommentSection from './components/CommentSection.vue'; import UpdateDialog from './components/UpdateDialog.vue'; import { usePlayerStore } from './stores/player'; +import { getCoverUrl } from './utils/song'; +import { useOnlineStatus } from './composables/useOnlineStatus'; +import { showToast } from './composables/useToast'; import { useLyric } from './composables/UserLyric'; import { useUpdater } from './composables/useUpdater'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -293,6 +296,12 @@ const userStore = useUserStore(); const player = usePlayerStore(); const settings = useSettingsStore(); const updater = useUpdater(); +const { isOnline } = useOnlineStatus(); + +watch(isOnline, (val, old) => { + if (val && !old) showToast('网络已恢复', 'success'); + else if (!val && old) showToast('网络已断开,部分功能不可用', 'error'); +}); const createdPlaylists = ref([]); const subPlaylists = ref([]); @@ -302,6 +311,7 @@ const searchQuery = ref(''); const showCloseModal = ref(false); const closeDontAskAgain = ref(false); const windowVisible = ref(true); +const keepAliveInclude = ref(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']); watch(() => settings.theme, (val) => { document.documentElement.setAttribute('data-theme', val); @@ -321,7 +331,7 @@ const roamCoverError = ref(false); const roamTab = ref<'lyric' | 'comment'>('lyric'); const roamCoverUrl = computed(() => { if (!roamSong.value) return ''; - return roamSong.value.al?.picUrl || roamSong.value.album?.picUrl || ''; + return getCoverUrl(roamSong.value) || ''; }); watch(roamCoverUrl, () => { roamCoverError.value = false; }); let roamResizeObserver: ResizeObserver | null = null; @@ -428,6 +438,8 @@ watch(() => userStore.isLoggedIn, (val) => { }); onMounted(async () => { + document.addEventListener('contextmenu', (e) => e.preventDefault()); + if (userStore.isLoggedIn) { loadPlaylists(); player.loadLikedIds(); @@ -497,9 +509,11 @@ onMounted(() => { }); const unlisten4 = listen('window-hidden', () => { windowVisible.value = false; + keepAliveInclude.value = []; }); const unlisten5 = listen('window-shown', () => { windowVisible.value = true; + keepAliveInclude.value = ['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']; }); onBeforeUnmount(() => { diff --git a/src/components/PlayerBar.vue b/src/components/PlayerBar.vue index 3212bc7..e745095 100644 --- a/src/components/PlayerBar.vue +++ b/src/components/PlayerBar.vue @@ -14,8 +14,8 @@
-
- +
+
@@ -142,43 +142,25 @@

去发现好听的音乐吧

-
-
- -
- -
-
-
- - - -
-
-
-
-

- {{ song.name }} -

-

- -

-
- -
+ + +
@@ -200,9 +182,11 @@ import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue' import { usePlayerStore, PlayMode } from '../stores/player'; import { useDownload } from '../composables/useDownload'; import { formatTime } from '../utils/format'; +import { getCoverUrl } from '../utils/song'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { useRouter } from 'vue-router'; +import SongListItem from './SongListItem.vue'; const router = useRouter(); const player = usePlayerStore(); @@ -228,7 +212,7 @@ onBeforeUnmount(() => { if (unlistenCache) unlistenCache(); }); -const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' }; +const modeTexts: Record = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' }; const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环'); function togglePlayMode() { const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one']; diff --git a/src/components/SongListItem.vue b/src/components/SongListItem.vue new file mode 100644 index 0000000..bfa64dd --- /dev/null +++ b/src/components/SongListItem.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/composables/useDownload.ts b/src/composables/useDownload.ts index 485a678..f643d9a 100644 --- a/src/composables/useDownload.ts +++ b/src/composables/useDownload.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { useSettingsStore } from '../stores/settings'; import { showToast } from '../composables/useToast'; +import { getCoverUrl, type Song } from '../utils/song'; interface DownloadTask { id: number; @@ -72,7 +73,7 @@ function getDownloadProgress(songId: number): number { 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 }) { +async function downloadSong(song: Song) { if (downloadingIds.has(song.id)) return; if (localSongIds.has(song.id)) { showToast(`${song.name} 已下载`, 'info'); @@ -80,10 +81,10 @@ async function downloadSong(song: { id: number; name: string; ar?: { name: strin } 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; + const artist = song.ar?.map(a => a.name).join(' / ') || '未知'; + const albumName = song.al?.name || null; + const durationVal = song.dt || null; + const coverUrl = getCoverUrl(song) || null; downloadingIds.add(song.id); tasks.push({ id: song.id, name: song.name, progress: 0 }); diff --git a/src/composables/useOnlineStatus.ts b/src/composables/useOnlineStatus.ts new file mode 100644 index 0000000..c451cc3 --- /dev/null +++ b/src/composables/useOnlineStatus.ts @@ -0,0 +1,30 @@ +import { ref, onMounted, onBeforeUnmount } from 'vue'; + +const isOnline = ref(navigator.onLine); + +function update() { + isOnline.value = navigator.onLine; +} + +let refCount = 0; + +export function useOnlineStatus() { + onMounted(() => { + refCount++; + if (refCount === 1) { + window.addEventListener('online', update); + window.addEventListener('offline', update); + } + }); + + onBeforeUnmount(() => { + refCount--; + if (refCount <= 0) { + refCount = 0; + window.removeEventListener('online', update); + window.removeEventListener('offline', update); + } + }); + + return { isOnline }; +} diff --git a/src/composables/usePageCache.ts b/src/composables/usePageCache.ts new file mode 100644 index 0000000..cbf97d1 --- /dev/null +++ b/src/composables/usePageCache.ts @@ -0,0 +1,24 @@ +const cache = new Map(); +const TTL = 5 * 60 * 1000; + +export function pageCacheGet(key: string): any | null { + const entry = cache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > TTL) { + cache.delete(key); + return null; + } + return entry.data; +} + +export function pageCacheSet(key: string, data: any) { + cache.set(key, { data, ts: Date.now() }); +} + +export function pageCacheDelete(key: string) { + cache.delete(key); +} + +export function pageCacheInvalidate(key: string) { + cache.delete(key); +} diff --git a/src/composables/useUpdater.ts b/src/composables/useUpdater.ts index a6a2cb0..95312e1 100644 --- a/src/composables/useUpdater.ts +++ b/src/composables/useUpdater.ts @@ -65,8 +65,7 @@ export function useUpdater() { } const ignored = getIgnoredVersion() - if (info.version === ignored) { - if (!silent) error.value = '当前已是最新版本' + if (info.version === ignored && silent) { return null } diff --git a/src/router/index.ts b/src/router/index.ts index 808736a..f2cfc52 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -9,7 +9,6 @@ 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 }, @@ -19,14 +18,28 @@ const routes = [ { path: '/recent', name: 'recent', component: RecentPlays }, { path: '/daily', name: 'daily', component: DailySongs }, { path: '/local-music', name: 'local-music', component: LocalMusic }, - { path: '/login', name: 'login', component: Login }, + { path: '/login', name: 'login', component: Login, meta: { guest: true } }, { path: '/playlist/:id', name: 'playlist', component: PlaylistDetail }, { path: '/artist/:id', name: 'artist', component: () => import('@/views/ArtistDetail.vue') }, { path: '/album/:id', name: 'album', component: () => import('@/views/AlbumDetail.vue') }, { path: '/settings', name: 'settings', component: Settings }, ]; -export default createRouter({ +const router = createRouter({ history: createWebHistory(), routes, -}); \ No newline at end of file +}); + +router.beforeEach((to) => { + if (to.meta.guest) { + const raw = localStorage.getItem('user'); + if (raw) { + try { + const data = JSON.parse(raw); + if (data?.userId) return { name: 'home' }; + } catch {} + } + } +}); + +export default router; diff --git a/src/stores/player.ts b/src/stores/player.ts index 691249b..5312b7a 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -1,35 +1,16 @@ import { defineStore } from 'pinia'; import { ref, watch, nextTick } from 'vue'; import { invoke } from '@tauri-apps/api/core'; -import { normalizeSong } from '../utils/song'; +import { normalizeSong, type Song } from '../utils/song'; import { useSettingsStore } from './settings'; import { useUserStore } from './user'; import { showToast } from '../composables/useToast'; export type PlayMode = 'loop' | 'shuffle' | 'repeat-one'; +export type { Song }; -export interface Song { - id: number; - name: string; - ar: { id?: number; name: string }[]; - al: { id?: number; picUrl: string; name?: string }; - dt?: number; - - album?: { picUrl?: string; name?: string }; - artists?: { name: string }[]; - duration?: number; - localPath?: string; -} - -const cacheProgress = ref(0); - -import { listen } from '@tauri-apps/api/event'; - -export function setupCacheProgressListener() { - listen('cache-progress', (event) => { - cacheProgress.value = event.payload; - }); -} +import { listen, emit } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; function loadRecentLocal(): Song[] { try { @@ -55,15 +36,35 @@ export const usePlayerStore = defineStore('player', () => { const queue = ref([]); const currentIndex = ref(-1); - const volume = ref(100); + + const settings = useSettingsStore(); + const volume = ref(settings.volume); + + watch(volume, (val) => { settings.volume = val; }); let tickInterval: ReturnType | null = null; + function setTickInterval(v: ReturnType | null) { _tickInterval = v; tickInterval = v; } const recentLocal = ref(loadRecentLocal()); const MAX_RECENT = 200; const likedIds = ref>(loadLikedIdsFromStorage()); + function emitPlaybackState() { + const song = currentSong.value; + const status = playing.value ? 'playing' : (song ? 'paused' : 'stopped'); + emit('playback-state', { + status, + title: song?.name || '', + album: song?.al?.name || '', + artists: song?.ar?.map(a => a.name) || [], + coverUrl: song?.al?.picUrl || '', + durationUs: (song?.dt || 0) * 1000, + positionUs: Math.round(currentTime.value * 1_000_000), + volume: volume.value / 100, + }); + } + function isLiked(songId: number): boolean { return likedIds.value.has(songId); } @@ -125,8 +126,8 @@ export const usePlayerStore = defineStore('player', () => { let fmVipSkipCount = 0; const MAX_FM_VIP_SKIP = 10; - async function playFmSong(song: any) { - if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } + async function playFmSong(song: Song) { + if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); } if (!song.dt || song.dt === 0) { try { const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) }); @@ -148,7 +149,6 @@ export const usePlayerStore = defineStore('player', () => { fmSong.value = song; currentSong.value = song; try { - 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; @@ -180,6 +180,7 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0; startTick(); addRecent(song); + emitPlaybackState(); } catch (e) { console.error('FM播放失败', e); playing.value = false; @@ -195,6 +196,15 @@ export const usePlayerStore = defineStore('player', () => { disableFmMode(); const idx = queue.value.findIndex(s => s.id === song.id); + if (idx !== -1 && idx === currentIndex.value && currentSong.value?.id === song.id) { + if (!playing.value) { + await invoke('resume_audio'); + playing.value = true; + startTick(); + } + return; + } + if (idx === -1) { queue.value.push(song); currentIndex.value = queue.value.length - 1; @@ -207,6 +217,21 @@ export const usePlayerStore = defineStore('player', () => { async function playFromList(songs: Song[], startIndex: number) { disableFmMode(); if (songs.length === 0) return; + + const targetSong = songs[startIndex]; + if (targetSong && currentSong.value?.id === targetSong.id && currentIndex.value >= 0) { + const sameQueue = queue.value.length === songs.length + && queue.value.every((s, i) => s.id === songs[i].id); + if (sameQueue) { + if (!playing.value) { + await invoke('resume_audio'); + playing.value = true; + startTick(); + } + return; + } + } + queue.value = [...songs]; currentIndex.value = Math.max(0, Math.min(startIndex, songs.length - 1)); await playCurrent(); @@ -215,23 +240,14 @@ export const usePlayerStore = defineStore('player', () => { let vipSkipCount = 0; const MAX_VIP_SKIP = 10; - let audioStartedResolve: (() => void) | null = null; - - listen('audio-started', () => { - if (audioStartedResolve) { - audioStartedResolve(); - audioStartedResolve = null; - } - }); - function waitForAudioStart(): Promise { return new Promise((resolve) => { - audioStartedResolve = resolve; + _audioStartedResolve = resolve; }); } async function playCurrent() { - if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } + if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); } const song = queue.value[currentIndex.value]; if (!song?.id) { console.error('无效的歌曲数据', song); @@ -242,18 +258,18 @@ export const usePlayerStore = defineStore('player', () => { currentSong.value = song; playing.value = false; currentTime.value = 0; - duration.value = (song.dt || song.duration || 0) / 1000; + duration.value = (song.dt || 0) / 1000; if (song.localPath) { await invoke('play_local_audio', { path: song.localPath }); await waitForAudioStart(); playing.value = true; - startTick(); - addRecent(song); - return; - } + startTick(); + addRecent(song); + emitPlaybackState(); + 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; @@ -282,24 +298,56 @@ export const usePlayerStore = defineStore('player', () => { startTick(); addRecent(song); vipSkipCount = 0; + emitPlaybackState(); } catch (e) { console.error('播放失败', e); playing.value = false; } } + let onSeekStart: (() => void) | null = null; + function startTick() { if (tickInterval) clearInterval(tickInterval); - tickInterval = setInterval(() => { + let seekGuard = false; + onSeekStart = () => { seekGuard = true; }; + let syncCounter = 1; + let lastSyncPos = -1; + let backendFrozen = false; + setTickInterval(setInterval(async () => { if (playing.value && duration.value > 0) { - if (currentTime.value < duration.value) { - currentTime.value += 0.25; - if (currentTime.value > duration.value) { - currentTime.value = duration.value; + if (seekGuard) return; + syncCounter++; + if (syncCounter >= 2) { + syncCounter = 0; + try { + const pos = await invoke('get_audio_position'); + if (pos >= currentTime.value - 0.5) { + currentTime.value = pos; + } + if (lastSyncPos < 0) { + lastSyncPos = pos; + } else if (pos <= lastSyncPos + 0.05) { + backendFrozen = true; + lastSyncPos = pos; + } else { + backendFrozen = false; + lastSyncPos = pos; + } + } catch {} + } else { + if (!backendFrozen) { + const next = currentTime.value + 0.25; + if (next <= duration.value) { + currentTime.value = next; + } } } + if (currentTime.value > duration.value) { + currentTime.value = duration.value; + } } - }, 250); + }, 250)); } async function toggle() { @@ -310,6 +358,7 @@ export const usePlayerStore = defineStore('player', () => { await invoke('resume_audio'); playing.value = true; } + emitPlaybackState(); } async function stop() { @@ -317,8 +366,9 @@ export const usePlayerStore = defineStore('player', () => { playing.value = false; currentSong.value = null; currentTime.value = 0; - if (tickInterval) clearInterval(tickInterval); + if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); } disableFmMode(); + emitPlaybackState(); } @@ -365,8 +415,11 @@ export const usePlayerStore = defineStore('player', () => { async function seek(time: number) { try { - await invoke('seek_audio', { time }); currentTime.value = time; + if (onSeekStart) onSeekStart(); + await invoke('seek_audio', { time }); + startTick(); + emitPlaybackState(); } catch (e) { console.error('seek 失败', e); } @@ -376,6 +429,7 @@ export const usePlayerStore = defineStore('player', () => { const newVol = Math.max(0, Math.min(100, volume.value + delta)); volume.value = newVol; await invoke('set_volume', { vol: newVol / 100 }); + emitPlaybackState(); } @@ -457,7 +511,7 @@ export const usePlayerStore = defineStore('player', () => { // -------- FM 专属状态 -------- -const fmSong = ref(null); +const fmSong = ref(null); const fmPlaying = ref(false); async function loadFm() { @@ -498,12 +552,57 @@ async function nextFm() { await loadFm(); } +let _audioStartedResolve: (() => void) | null = null; +let _tickInterval: ReturnType | null = null; + +listen('audio-started', () => { + if (_audioStartedResolve) { + _audioStartedResolve(); + _audioStartedResolve = null; + } +}); + listen('audio-ended', () => { - if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } - if (isFmMode.value && fmNextCallback) { - fmNextCallback(); - } else { - next(); + if (_tickInterval) { clearInterval(_tickInterval); _tickInterval = null; } + const player = usePlayerStore(); + player.next(); +}); + +listen('mpris-command', (event) => { + const cmd = event.payload; + const player = usePlayerStore(); + if (cmd === 'Next') { + player.next(); + } else if (cmd === 'Previous') { + player.prev(); + } else if (cmd === 'PlayPause') { + player.toggle(); + } else if (cmd === 'Play') { + if (!player.playing) player.toggle(); + } else if (cmd === 'Pause') { + if (player.playing) player.toggle(); + } else if (cmd === 'Stop') { + player.stop(); + } else if (cmd.startsWith('SetVolume:')) { + const vol = parseFloat(cmd.slice(10)); + if (!isNaN(vol)) { + player.volume = Math.round(vol * 100); + invoke('set_volume', { vol }).catch(() => {}); + } + } else if (cmd.startsWith('Seek:')) { + const offsetUs = parseInt(cmd.slice(5), 10); + const offsetSec = offsetUs / 1_000_000; + const newPos = Math.max(0, Math.min(player.currentTime + offsetSec, player.duration)); + player.seek(newPos); + } else if (cmd.startsWith('SetPosition:')) { + const posUs = parseInt(cmd.slice(13), 10); + const posSec = posUs / 1_000_000; + player.seek(posSec); + } else if (cmd === 'Raise') { + getCurrentWindow().show().catch(() => {}); + getCurrentWindow().setFocus().catch(() => {}); + } else if (cmd === 'Quit') { + getCurrentWindow().close().catch(() => {}); } }); diff --git a/src/stores/settings.ts b/src/stores/settings.ts index a2fabbb..7c6fc73 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -2,13 +2,13 @@ import { defineStore } from 'pinia'; import { ref, watch } from 'vue'; export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires'; -export type ThemeName = 'green' | 'rose' | 'blue' | 'violet' | 'orange' | 'cyan' | 'pink'; +export type ThemeName = 'blue' | 'green' | 'rose' | 'violet' | 'orange' | 'cyan' | 'pink'; export type CloseAction = 'ask' | 'minimize' | 'exit'; export const themeLabels: Record = { + blue: '天蓝', green: '翠绿', rose: '玫红', - blue: '天蓝', violet: '紫罗兰', orange: '橙色', cyan: '青色', @@ -16,9 +16,9 @@ export const themeLabels: Record = { }; export const themeColors: Record = { + blue: '#3b82f6', green: '#22c55e', rose: '#f43f5e', - blue: '#3b82f6', violet: '#8b5cf6', orange: '#f97316', cyan: '#06b6d4', @@ -50,11 +50,11 @@ export const defaultShortcuts: Record = { next: { key: 'Control+ArrowRight', label: '下一首' }, volUp: { key: 'Control+ArrowUp', label: '音量增加' }, volDown: { key: 'Control+ArrowDown', label: '音量减小' }, - globalPlayPause: { key: 'Alt+Control+KeyP', label: '播放/暂停(全局)' }, - globalPrev: { key: 'Alt+Control+ArrowLeft', label: '上一首(全局)' }, - globalNext: { key: 'Alt+Control+ArrowRight', label: '下一首(全局)' }, - globalVolUp: { key: 'Alt+Control+ArrowUp', label: '音量增加(全局)' }, - globalVolDown: { key: 'Alt+Control+ArrowDown', label: '音量减小(全局)' }, + globalPlayPause: { key: 'Control+Alt+KeyP', label: '播放/暂停(全局)' }, + globalPrev: { key: 'Control+Alt+ArrowLeft', label: '上一首(全局)' }, + globalNext: { key: 'Control+Alt+ArrowRight', label: '下一首(全局)' }, + globalVolUp: { key: 'Control+Alt+ArrowUp', label: '音量增加(全局)' }, + globalVolDown: { key: 'Control+Alt+ArrowDown', label: '音量减小(全局)' }, }; interface SettingsData { @@ -64,6 +64,7 @@ interface SettingsData { closeAction: CloseAction; shortcuts: Record; outputDevice: string | null; + volume: number; } function loadSettings(): SettingsData { @@ -71,25 +72,27 @@ function loadSettings(): SettingsData { const raw = localStorage.getItem('app_settings'); if (raw) { const parsed = JSON.parse(raw); - const theme = parsed.theme || parsed.accentColor || 'green'; - const validThemes: ThemeName[] = ['green', 'rose', 'blue', 'violet', 'orange', 'cyan', 'pink']; + const theme = parsed.theme || parsed.accentColor || 'blue'; + const validThemes: ThemeName[] = ['blue', 'green', 'rose', 'violet', 'orange', 'cyan', 'pink']; return { audioQuality: parsed.audioQuality || 'standard', downloadPath: parsed.downloadPath || '', - theme: validThemes.includes(theme) ? theme : 'green', + theme: validThemes.includes(theme) ? theme : 'blue', closeAction: parsed.closeAction || 'ask', shortcuts: { ...defaultShortcuts, ...(parsed.shortcuts || {}) }, outputDevice: parsed.outputDevice || null, + volume: typeof parsed.volume === 'number' ? parsed.volume : 100, }; } } catch {} return { audioQuality: 'standard', downloadPath: '', - theme: 'green', + theme: 'blue', closeAction: 'ask', shortcuts: { ...defaultShortcuts }, outputDevice: null, + volume: 100, }; } @@ -102,6 +105,7 @@ export const useSettingsStore = defineStore('settings', () => { const closeAction = ref(saved.closeAction || 'ask'); const shortcuts = ref>(saved.shortcuts); const outputDevice = ref(saved.outputDevice); + const volume = ref(saved.volume); function setAudioQuality(q: AudioQuality) { audioQuality.value = q; @@ -134,13 +138,14 @@ export const useSettingsStore = defineStore('settings', () => { function resetAll() { audioQuality.value = 'standard'; downloadPath.value = ''; - theme.value = 'green'; + theme.value = 'blue'; closeAction.value = 'ask'; shortcuts.value = { ...defaultShortcuts }; outputDevice.value = null; + volume.value = 100; } - watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice], () => { + watch([audioQuality, downloadPath, theme, closeAction, shortcuts, outputDevice, volume], () => { const data: SettingsData = { audioQuality: audioQuality.value, downloadPath: downloadPath.value, @@ -148,6 +153,7 @@ export const useSettingsStore = defineStore('settings', () => { closeAction: closeAction.value, shortcuts: shortcuts.value, outputDevice: outputDevice.value, + volume: volume.value, }; localStorage.setItem('app_settings', JSON.stringify(data)); }, { deep: true }); @@ -159,6 +165,7 @@ export const useSettingsStore = defineStore('settings', () => { closeAction, shortcuts, outputDevice, + volume, setAudioQuality, setDownloadPath, setTheme, diff --git a/src/style.css b/src/style.css index a4a8496..fe074be 100644 --- a/src/style.css +++ b/src/style.css @@ -195,6 +195,13 @@ overflow: hidden; overscroll-behavior: none; touch-action: none; + user-select: none; + -webkit-user-select: none; + } + + input, textarea, [contenteditable="true"] { + user-select: text; + -webkit-user-select: text; } ::-webkit-scrollbar { diff --git a/src/utils/format.ts b/src/utils/format.ts index 1a4725d..3e6c441 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -12,9 +12,18 @@ export function formatTime(sec: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } +const YI = 100_000_000; +const WAN = 10_000; + 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) + '万'; + if (count >= YI) return (count / YI).toFixed(1) + '亿'; + if (count >= WAN) return (count / WAN).toFixed(1) + '万'; return count.toString(); } + +export function formatDate(ts: number): string { + if (!ts) return ''; + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} diff --git a/src/utils/song.ts b/src/utils/song.ts index 698c649..fa41d2f 100644 --- a/src/utils/song.ts +++ b/src/utils/song.ts @@ -1,22 +1,35 @@ -/** - * 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理 - */ -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.al?.id && normalized.album?.id) { - normalized.al = { ...normalized.al, id: normalized.album.id }; - } - if (!normalized.ar || normalized.ar.length === 0) { - normalized.ar = normalized.artists || []; - } - if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) { - normalized.dt = 0; - } - return normalized; -} \ No newline at end of file +export interface Song { + id: number; + name: string; + ar: { id?: number; name: string }[]; + al: { id?: number; picUrl: string; name?: string }; + dt?: number; + localPath?: string; +} + +export function normalizeSong(song: any): Song { + const al = { + id: song.al?.id || song.album?.id, + picUrl: song.al?.picUrl || song.album?.picUrl || '', + name: song.al?.name || song.album?.name, + }; + const ar = (song.ar && song.ar.length > 0) ? song.ar : (song.artists || []); + let dt = song.dt || song.duration || 0; + if (dt < 100 || dt > 7200000) dt = 0; + return { + id: song.id, + name: song.name, + ar, + al, + dt, + localPath: song.localPath, + }; +} + +export function getCoverUrl(song: Song | null, sizeParam = ''): string { + if (!song) return ''; + const raw = song.al?.picUrl || ''; + if (!raw) return ''; + if (!sizeParam || raw.startsWith('data:')) return raw; + return raw + sizeParam; +} diff --git a/src/views/AlbumDetail.vue b/src/views/AlbumDetail.vue index 8820940..2902e12 100644 --- a/src/views/AlbumDetail.vue +++ b/src/views/AlbumDetail.vue @@ -11,7 +11,7 @@

{{ album.name }}

@@ -92,25 +77,18 @@ import { ref, onMounted, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../stores/player'; -import { useDownload } from '../composables/useDownload'; -import { formatDuration } from '../utils/format'; -import SongItemMenu from '../components/SongItemMenu.vue'; +import { normalizeSong, type Song } from '../utils/song'; +import { formatDate } from '../utils/format'; +import SongListItem from '../components/SongListItem.vue'; const route = useRoute(); const router = useRouter(); const player = usePlayerStore(); -const download = useDownload(); const album = ref(null); -const songs = ref([]); +const songs = ref([]); const loading = ref(true); -function formatDate(ts: number): string { - if (!ts) return ''; - const d = new Date(ts); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -} - async function fetchAlbum(id: number) { loading.value = true; album.value = null; @@ -118,13 +96,8 @@ async function fetchAlbum(id: number) { try { const jsonStr: string = await invoke('album_detail', { id }); const data = JSON.parse(jsonStr); - const a = data.album; - if (a) { - delete a.uid; - if (a.artists) a.artists.forEach((ar: any) => delete ar.uid); - } - album.value = a; - songs.value = data.songs || []; + album.value = data.album; + songs.value = (data.songs || []).map(normalizeSong); } catch (e) { console.error(e); } finally { @@ -140,15 +113,6 @@ watch(() => route.params.id, (newId) => { if (newId) fetchAlbum(Number(newId)); }); -function isCurrentSong(songId: number): boolean { - return player.currentSong?.id === songId; -} - -async function playSingle(song: any) { - 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); diff --git a/src/views/ArtistDetail.vue b/src/views/ArtistDetail.vue index 8fa203a..a04c113 100644 --- a/src/views/ArtistDetail.vue +++ b/src/views/ArtistDetail.vue @@ -41,52 +41,37 @@ diff --git a/src/views/Discover.vue b/src/views/Discover.vue index 5959873..669a29a 100644 --- a/src/views/Discover.vue +++ b/src/views/Discover.vue @@ -2,7 +2,6 @@

发现音乐

- -

🔥 热门搜索

@@ -25,45 +23,19 @@
- - - -
搜索中...
-
- -
-

{{ song.name }}

-

- - -

-
- - -
+ :song="song" + :index="index" + show-download + show-menu + cover-size="w-12 h-12" + container-class="backdrop-blur-md bg-subtle hover:bg-muted border border-line-2" + @click="player.playFromList(results, index)" + />

无结果

@@ -72,38 +44,43 @@ diff --git a/src/views/FavoriteSongs.vue b/src/views/FavoriteSongs.vue index c3d23e5..95fb1d5 100644 --- a/src/views/FavoriteSongs.vue +++ b/src/views/FavoriteSongs.vue @@ -19,66 +19,52 @@
加载中...
暂无喜欢的音乐
-
- {{ index + 1 }} - -
-

{{ song.name }}

-

- - -

-
- - - - {{ formatDuration(song.dt) }} -
+ />
diff --git a/src/views/Home.vue b/src/views/Home.vue index b887a9e..d6960b6 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -13,7 +13,7 @@

📅 {{ todayStr }}

每日推荐

-

根据你的口味生成,每天 6:00 更新

+

根据你的口味生成,每天凌晨更新

🎧
@@ -80,9 +80,9 @@

🎯 为你推荐

-
+
+ class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer max-w-[220px] justify-self-center w-full">

{{ pl.name }}

@@ -95,9 +95,9 @@

📈 热门歌单

-
+
+ class="bg-subtle rounded-xl overflow-hidden hover:bg-muted transition cursor-pointer backdrop-blur-sm max-w-[220px] justify-self-center w-full">

{{ pl.name }}

@@ -109,15 +109,21 @@ diff --git a/src/views/Roam.vue b/src/views/Roam.vue index 1c4622c..d0599b3 100644 --- a/src/views/Roam.vue +++ b/src/views/Roam.vue @@ -64,11 +64,13 @@ import { ref, computed, watch, onMounted } from 'vue'; import { usePlayerStore } from '../stores/player'; import { invoke } from '@tauri-apps/api/core'; -import { normalizeSong } from '../utils/song'; +import { normalizeSong, getCoverUrl } from '../utils/song'; import { useRouter } from 'vue-router'; +import { useOnlineStatus } from '../composables/useOnlineStatus'; const player = usePlayerStore(); const router = useRouter(); +const { isOnline } = useOnlineStatus(); const coverError = ref(false); const currentSong = computed(() => { @@ -80,7 +82,7 @@ const currentSong = computed(() => { const coverUrl = computed(() => { if (!currentSong.value) return ''; - return currentSong.value.al?.picUrl || currentSong.value.album?.picUrl || ''; + return getCoverUrl(currentSong.value) || ''; }); watch(coverUrl, () => { coverError.value = false; }); @@ -109,4 +111,10 @@ async function startFm() { async function nextSong() { await startFm(); } + +watch(isOnline, (val, old) => { + if (val && !old && !currentSong.value) { + startFm(); + } +}); diff --git a/src/views/Search.vue b/src/views/Search.vue deleted file mode 100644 index 1458070..0000000 --- a/src/views/Search.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 3629cab..76d178f 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -105,18 +105,18 @@
@@ -361,6 +361,7 @@ function formatShortcut(key: string): string { .replace('ArrowRight', '→') .replace('ArrowUp', '↑') .replace('ArrowDown', '↓') + .replace(/Key([A-Z])/g, '$1') .replace(/\+/g, ' + '); }