From 718d3ed64100412b4916c4db91ac7296b38d2899 Mon Sep 17 00:00:00 2001 From: Atdunbg Date: Fri, 15 May 2026 02:24:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3.0=20-=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E3=80=81=E6=9C=AC=E5=9C=B0=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E3=80=81=E4=B8=8B=E8=BD=BD=E7=B3=BB=E7=BB=9F=E3=80=81=E6=BC=AB?= =?UTF-8?q?=E6=B8=B8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 新功能 - 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载 - 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」 - 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长) - 封面补全:本地音乐缺少封面时自动从网易云 API 获取 - 更新信息:接入 Gitea Releases API,查看最新版更新日志 ### 修复 - 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick) - 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片 - 修复 PlayerBar 无封面歌曲显示破损图片 - 修复下载路径修改后不生效(Rust serde camelCase 映射) - 修复本地音乐始终只显示默认路径歌曲 - 修复下载完成提示弹出 4 次 - 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步) ### 优化 - PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示 - audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended - player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步 - 切歌/停止时立即清除 tickInterval 防止重复触发 next() --- package-lock.json | 45 ++- package.json | 6 +- src-tauri/Cargo.lock | 328 +++++++++++++++++++++- src-tauri/Cargo.toml | 11 +- src-tauri/capabilities/default.json | 7 +- src-tauri/src/api.rs | 410 +++++++++++++++++++++++++++- src-tauri/src/audio.rs | 395 ++++++++++++++++++++++----- src-tauri/src/lib.rs | 17 +- src-tauri/tauri.conf.json | 9 +- src/App.vue | 101 ++++++- src/components/PlayerBar.vue | 43 ++- src/composables/useDownload.ts | 135 +++++++++ src/router/index.ts | 2 + src/stores/player.ts | 170 +++++++++--- src/stores/settings.ts | 53 +++- src/utils/song.ts | 5 +- src/views/DailySongs.vue | 9 +- src/views/Discover.vue | 28 +- src/views/FavoriteSongs.vue | 9 +- src/views/LocalMusic.vue | 225 +++++++++++++++ src/views/PlaylistDetail.vue | 10 +- src/views/RecentPlays.vue | 9 +- src/views/Roam.vue | 20 +- src/views/Search.vue | 28 +- src/views/Settings.vue | 264 ++++++++++++++++-- 25 files changed, 2123 insertions(+), 216 deletions(-) create mode 100644 src/composables/useDownload.ts create mode 100644 src/views/LocalMusic.vue diff --git a/package-lock.json b/package-lock.json index f8af701..601b0c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,19 @@ { - "name": "demo", + "name": "nekosonic", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "demo", + "name": "nekosonic", "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "axios": "^1.16.0", "howler": "^2.2.4", "pinia": "^3.0.4", @@ -1485,6 +1489,24 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-global-shortcut": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz", + "integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", @@ -1494,6 +1516,24 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -1507,7 +1547,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } diff --git a/package.json b/package.json index 477ea3e..58f1b11 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nekosonic", "private": true, - "version": "0.1.0", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,11 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "axios": "^1.16.0", "howler": "^2.2.4", "pinia": "^3.0.4", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 93caa1f..5b5af21 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,9 +4,13 @@ version = 4 [[package]] name = "Nekosonic" -version = "0.1.0" +version = "0.3.0" dependencies = [ + "base64 0.22.1", "cpal", + "dirs 5.0.1", + "futures-util", + "lofty", "ncm-api-rs", "reqwest 0.12.28", "rodio", @@ -14,7 +18,10 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-single-instance", "tokio", ] @@ -859,6 +866,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbus" version = "0.9.11" @@ -923,13 +936,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -940,7 +974,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1473,6 +1507,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1598,6 +1642,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2310,6 +2372,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "log" version = "0.4.29" @@ -2723,6 +2811,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2827,6 +2916,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "ogg_pager" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2915,6 +3013,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3307,7 +3411,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3405,6 +3509,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3503,12 +3618,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -3543,10 +3660,34 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4290,7 +4431,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4340,7 +4481,7 @@ checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4410,6 +4551,63 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -4432,6 +4630,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" @@ -4920,7 +5128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5236,6 +5444,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -5605,6 +5826,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5656,6 +5886,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5713,6 +5958,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5731,6 +5982,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5749,6 +6006,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5779,6 +6042,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5797,6 +6066,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5815,6 +6090,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5833,6 +6114,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5989,7 +6276,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", @@ -6044,6 +6331,29 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7263ea1..41354ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Nekosonic" -version = "0.1.0" +version = "0.3.0" description = "A Simple music app" authors = ["atdunbg"] edition = "2021" @@ -21,12 +21,19 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" +tauri-plugin-global-shortcut = "2" +tauri-plugin-dialog = "2" rodio = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" cpal = { version = "0.15" } -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "stream"] } +futures-util = "0.3" +dirs = "5" +lofty = "0.22" +base64 = "0.22" ncm-api-rs = "0.1" tokio = { version = "1", features = ["rt", "sync"] } +tauri-plugin-process = "2.3.1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8b0768b..04a1157 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,11 @@ "core:window:allow-toggle-maximize", "core:window:allow-unminimize", "core:window:allow-show", - "core:window:allow-set-focus" + "core:window:allow-set-focus", + "global-shortcut:allow-is-registered", + "global-shortcut:allow-register", + "global-shortcut:allow-unregister", + "dialog:allow-open", + "process:allow-restart" ] } diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs index c6cfc0a..43bea60 100644 --- a/src-tauri/src/api.rs +++ b/src-tauri/src/api.rs @@ -1,12 +1,18 @@ use ncm_api_rs::{create_client, ApiClient, Query}; use serde::Deserialize; -use tauri::{Manager, State}; +use serde_json::json; +use tauri::{Manager, State, Emitter}; use tokio::sync::Mutex; use std::sync::Mutex as StdMutex; use std::sync::atomic::Ordering; use std::fs; use std::path::PathBuf; +use std::io::Write; +use std::hash::{Hash, Hasher}; +use lofty::file::{AudioFile, TaggedFileExt}; +use lofty::tag::Accessor; +use base64::Engine; pub struct ApiController { client: Mutex, @@ -103,21 +109,54 @@ pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, A } #[derive(Deserialize)] -pub struct SongUrlQuery { pub id: u64, pub level: Option } +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 level = query.level.as_deref().unwrap_or("standard"); - let q = state.build_query() - .param("id", &query.id.to_string()) - .param("level", level); - let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?; - resp.body["data"][0]["url"].as_str() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .ok_or_else(|| "暂无播放源".into()) + + let resp = if query.fm_mode.unwrap_or(false) { + let mut fm_cookie = state.cookie.lock().ok().and_then(|g| g.clone()).unwrap_or_default(); + if !fm_cookie.contains("os=") { + fm_cookie = format!("{}; os=android; appver=8.10.05", fm_cookie); + } + let data = serde_json::json!({ + "ids": format!("[{}]", query.id), + "level": level, + "encodeType": "flac", + "feeProcess": "true" + }); + let option = ncm_api_rs::request::RequestOption { + crypto: ncm_api_rs::request::CryptoType::default(), + cookie: Some(fm_cookie), + ua: None, + proxy: None, + real_ip: None, + random_cn_ip: false, + e_r: None, + domain: None, + check_token: false, + }; + client.request( + "/api/song/enhance/player/url/v1", + data, + option, + ).await.map_err(|e| e.to_string())? + } else { + let q = state.build_query() + .param("id", &query.id.to_string()) + .param("level", level); + client.song_url_v1(&q).await.map_err(|e| e.to_string())? + }; + + let data = &resp.body["data"][0]; + let url = data["url"].as_str().filter(|s| !s.is_empty()); + if url.is_none() { + return Err("暂无播放源".into()); + } + Ok(data.to_string()) } /// 获取歌词 @@ -340,3 +379,352 @@ pub async fn exit_app(app_handle: tauri::AppHandle) { let _ = window.close(); } } + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalSongInfo { + pub id: u64, + pub name: String, + pub artist: String, + pub album: String, + pub duration: u64, + pub cover: Option, + pub filename: String, + pub file_size: u64, + pub path: String, + pub local: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadSongQuery { + pub id: u64, + pub name: String, + pub artist: String, + pub album: Option, + pub duration: Option, + pub cover_url: Option, + pub level: Option, + pub download_path: Option, +} + +#[tauri::command] +pub async fn download_song( + app_handle: tauri::AppHandle, + query: DownloadSongQuery, + state: State<'_, ApiController>, +) -> Result { + let level = query.level.as_deref().unwrap_or("standard"); + + let q = state.build_query() + .param("id", &query.id.to_string()) + .param("level", level); + let client = state.client.lock().await; + let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?; + let data = &resp.body["data"][0]; + let url = data["url"].as_str().filter(|s| !s.is_empty()); + if url.is_none() { + let free_trial = data.get("freeTrialInfo"); + if free_trial.is_some() && !free_trial.unwrap().is_null() { + return Err("VIP歌曲无法下载".into()); + } + return Err("暂无下载源,可能需要 VIP 权限".into()); + } + let url = url.unwrap(); + let free_trial = data.get("freeTrialInfo"); + if free_trial.is_some() && !free_trial.unwrap().is_null() { + return Err("VIP歌曲无法下载".into()); + } + let ext = if url.contains(".flac") { "flac" } else { "mp3" }; + drop(client); + + let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref()); + let _ = fs::create_dir_all(&download_dir); + + let safe_name = sanitize_filename(&query.name); + let safe_artist = sanitize_filename(&query.artist); + let filename = format!("{} - {}.{}", safe_artist, safe_name, ext); + let filepath = download_dir.join(&filename); + + if filepath.exists() { + return Err("文件已存在".into()); + } + + let resp = reqwest::get(url).await.map_err(|e| format!("下载失败: {}", e))?; + let total_size = resp.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + + let temp_path = filepath.with_extension(format!("{}.tmp", ext)); + let mut file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?; + + let mut stream = resp.bytes_stream(); + use futures_util::StreamExt; + let mut chunk_count: u64 = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("读取失败: {}", e))?; + file.write_all(&chunk).map_err(|e| format!("写入失败: {}", e))?; + downloaded += chunk.len() as u64; + chunk_count += 1; + if chunk_count % 8 == 0 || downloaded == total_size { + let progress = if total_size > 0 { + (downloaded as f64 / total_size as f64) * 100.0 + } else { + 0.0 + }; + let _ = app_handle.emit("download-progress", json!({ + "id": query.id, + "progress": progress, + "name": query.name, + })); + } + } + drop(file); + + fs::rename(&temp_path, &filepath).map_err(|e| format!("重命名失败: {}", e))?; + + let meta = json!({ + "id": query.id, + "name": query.name, + "artist": query.artist, + "album": query.album, + "duration": query.duration, + "coverUrl": query.cover_url, + "filename": filename, + }); + let meta_path = download_dir.join(format!("{}.json", query.id)); + let mut meta_file = fs::File::create(&meta_path).map_err(|e| format!("创建元数据失败: {}", e))?; + meta_file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes()) + .map_err(|e| format!("写入元数据失败: {}", e))?; + + let _ = app_handle.emit("download-progress", json!({ + "id": query.id, + "progress": 100.0, + "name": query.name, + })); + + Ok(filename) +} + +#[tauri::command] +pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option) -> Result, String> { + let download_dir = resolve_download_dir(&app_handle, download_path.as_deref()); + if !download_dir.exists() { + return Ok(Vec::new()); + } + + let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"]; + + let mut meta_map: std::collections::HashMap = std::collections::HashMap::new(); + let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(meta) = serde_json::from_str::(&content) { + if let Some(filename) = meta["filename"].as_str() { + meta_map.insert(filename.to_string(), meta); + } + } + } + } + } + + let mut songs: Vec = Vec::new(); + let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if !audio_exts.contains(&ext.to_lowercase().as_str()) { + continue; + } + + let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + + let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path); + + if let Some(meta) = meta_map.get(&filename) { + let meta_title = meta["name"].as_str().unwrap_or(""); + let meta_artist = meta["artist"].as_str().unwrap_or(""); + let meta_album = meta["album"].as_str().unwrap_or(""); + let meta_duration = meta["duration"].as_u64().unwrap_or(0); + let meta_cover_url = meta["coverUrl"].as_str().unwrap_or(""); + + let final_title = if title.is_empty() { meta_title.to_string() } else { title }; + let final_artist = if artist.is_empty() { meta_artist.to_string() } else { artist }; + let final_album = if album.is_empty() { meta_album.to_string() } else { album }; + let final_duration = if duration_ms == 0 { meta_duration } else { duration_ms }; + let final_cover = cover_b64.or_else(|| { + if meta_cover_url.is_empty() { None } else { Some(meta_cover_url.to_string()) } + }); + + songs.push(LocalSongInfo { + id: meta["id"].as_u64().unwrap_or(0), + name: final_title, + artist: final_artist, + album: final_album, + duration: final_duration, + cover: final_cover, + filename, + file_size, + path: path.to_string_lossy().to_string(), + local: true, + }); + } else { + let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let (parsed_artist, parsed_name) = parse_filename(&stem); + let final_title = if title.is_empty() { parsed_name } else { title }; + let final_artist = if artist.is_empty() { parsed_artist } else { artist }; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + filename.hash(&mut hasher); + let hash_id = hasher.finish(); + + songs.push(LocalSongInfo { + id: hash_id, + name: final_title, + artist: final_artist, + album, + duration: duration_ms, + cover: cover_b64, + filename, + file_size, + path: path.to_string_lossy().to_string(), + local: true, + }); + } + } + + Ok(songs) +} + +fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option) { + match lofty::read_from_path(path) { + Ok(tagged_file) => { + let properties = tagged_file.properties(); + let duration_ms = properties.duration().as_millis() as u64; + + let tag = tagged_file.primary_tag(); + let (title, artist, album) = if let Some(t) = tag { + let title = t.title().map(|s| s.to_string()).unwrap_or_default(); + let artist = t.artist().map(|s| s.to_string()).unwrap_or_default(); + let album = t.album().map(|s| s.to_string()).unwrap_or_default(); + (title, artist, album) + } else { + (String::new(), String::new(), String::new()) + }; + + let cover_b64 = if let Some(t) = tag { + if let Some(pic) = t.pictures().first() { + let data = pic.data(); + let mime = pic.mime_type().map(|m| m.to_string()).unwrap_or_else(|| "image/jpeg".to_string()); + let b64 = base64::engine::general_purpose::STANDARD.encode(data); + Some(format!("data:{};base64,{}", mime, b64)) + } else { + None + } + } else { + None + }; + + (title, artist, album, duration_ms, cover_b64) + } + Err(e) => { + eprintln!("[api] 读取音频元数据失败 {}: {}", path.display(), e); + (String::new(), String::new(), String::new(), 0, None) + } + } +} + +fn parse_filename(stem: &str) -> (String, String) { + if let Some(pos) = stem.find(" - ") { + let artist = &stem[..pos]; + let name = &stem[pos + 3..]; + (artist.trim().to_string(), name.trim().to_string()) + } else if let Some(pos) = stem.find('-') { + let artist = &stem[..pos]; + let name = &stem[pos + 1..]; + (artist.trim().to_string(), name.trim().to_string()) + } else { + ("".to_string(), stem.trim().to_string()) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteLocalSongQuery { + pub id: u64, + pub filename: String, + pub download_path: Option, +} + +#[tauri::command] +pub fn delete_local_song( + app_handle: tauri::AppHandle, + query: DeleteLocalSongQuery, +) -> Result<(), String> { + let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref()); + let file_path = download_dir.join(&query.filename); + let meta_path = download_dir.join(format!("{}.json", query.id)); + + if file_path.exists() { + fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?; + } + if meta_path.exists() { + fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?; + } + Ok(()) +} + +#[tauri::command] +pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option) -> Result { + let download_dir = resolve_download_dir(&app_handle, download_path.as_deref()); + let meta_path = download_dir.join(format!("{}.json", id)); + Ok(meta_path.exists()) +} + +fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf { + if let Some(path) = custom_path { + if !path.is_empty() { + return PathBuf::from(path); + } + } + get_default_download_dir(app_handle) +} + +fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf { + if let Ok(dir) = app_handle.path().app_data_dir() { + let download_dir = dir.join("downloads"); + return download_dir; + } + let music_dir = dirs::audio_dir().unwrap_or_else(|| { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) + }); + music_dir.join("Nekosonic") +} + +#[tauri::command] +pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String { + get_default_download_dir(&app_handle).to_string_lossy().to_string() +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| { + if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' + || c == '"' || c == '<' || c == '>' || c == '|' + { + '_' + } else { + c + } + }) + .collect::() + .trim() + .to_string() +} diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 6a7cf5b..af3f580 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,8 +1,8 @@ use rodio::{Decoder, OutputStream, Sink, Source}; use rodio::cpal::traits::{DeviceTrait, HostTrait}; -use std::io::Cursor; +use std::io::{Read, Seek, SeekFrom}; use std::sync::mpsc::{channel, Receiver, Sender}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::Duration; use tauri::AppHandle; @@ -11,6 +11,7 @@ use tauri::Emitter; // ---------- 命令 ---------- enum AudioCmd { Play(String), + PlayLocal(String), Pause, Resume, Stop, @@ -26,21 +27,25 @@ pub struct AudioController { impl AudioController { pub fn new(app_handle: AppHandle) -> Self { - let (tx, rx) = channel(); - let current_url = Arc::new(Mutex::new(None)); - let url_clone = current_url.clone(); - let ah_clone = app_handle.clone(); // 克隆一个用于闭包 - thread::spawn(move || audio_thread(rx, url_clone, ah_clone)); - AudioController { - tx, - current_url, + let (tx, rx) = channel(); + let current_url = Arc::new(Mutex::new(None)); + let url_clone = current_url.clone(); + let ah_clone = app_handle.clone(); + thread::spawn(move || audio_thread(rx, url_clone, ah_clone)); + AudioController { + tx, + current_url, + } } -} pub fn play_url(&self, url: &str) { *self.current_url.lock().unwrap() = Some(url.to_string()); let _ = self.tx.send(AudioCmd::Play(url.to_string())); } + pub fn play_local(&self, path: &str) { + *self.current_url.lock().unwrap() = Some(path.to_string()); + let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string())); + } pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); } pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); } pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); } @@ -55,44 +60,179 @@ impl AudioController { } } -use std::io::Read; +// ---------- 流式缓冲区 ---------- -fn download_audio_with_progress( +struct BufferState { + bytes: Vec, + done: bool, + cancelled: bool, +} + +struct SharedBuffer { + state: Mutex, + available: Condvar, +} + +impl SharedBuffer { + fn new() -> Self { + SharedBuffer { + state: Mutex::new(BufferState { + bytes: Vec::new(), + done: false, + cancelled: false, + }), + available: Condvar::new(), + } + } + + fn write_chunk(&self, chunk: &[u8]) { + let mut state = self.state.lock().unwrap(); + state.bytes.extend_from_slice(chunk); + self.available.notify_all(); + } + + fn mark_done(&self) { + let mut state = self.state.lock().unwrap(); + state.done = true; + self.available.notify_all(); + } + + fn cancel(&self) { + let mut state = self.state.lock().unwrap(); + state.cancelled = true; + self.available.notify_all(); + } + + fn len(&self) -> usize { + self.state.lock().unwrap().bytes.len() + } + + fn is_done(&self) -> bool { + self.state.lock().unwrap().done + } + + fn is_cancelled(&self) -> bool { + self.state.lock().unwrap().cancelled + } +} + +struct StreamingReader { + buffer: Arc, + pos: usize, +} + +impl StreamingReader { + fn new(buffer: Arc) -> Self { + StreamingReader { buffer, pos: 0 } + } +} + +impl Read for StreamingReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let mut state = self.buffer.state.lock().unwrap(); + loop { + let available = state.bytes.len().saturating_sub(self.pos); + if available > 0 { + let to_read = std::cmp::min(buf.len(), available); + buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]); + self.pos += to_read; + return Ok(to_read); + } + if state.done { + return Ok(0); + } + if state.cancelled { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled")); + } + let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap(); + state = result.0; + } + } +} + +impl Seek for StreamingReader { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let new_pos = match pos { + SeekFrom::Start(offset) => offset as i64, + SeekFrom::Current(offset) => self.pos as i64 + offset, + SeekFrom::End(offset) => { + let mut state = self.buffer.state.lock().unwrap(); + loop { + if state.done { + break state.bytes.len() as i64 + offset; + } + if state.cancelled { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled")); + } + let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap(); + state = result.0; + } + } + }; + if new_pos < 0 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek before start")); + } + let mut state = self.buffer.state.lock().unwrap(); + loop { + if new_pos as usize <= state.bytes.len() { + self.pos = new_pos as usize; + return Ok(self.pos as u64); + } + if state.done { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek past end")); + } + if state.cancelled { + return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled")); + } + let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap(); + state = result.0; + } + } +} + +fn download_audio_streaming( url: &str, + buffer: &SharedBuffer, app_handle: &AppHandle, -) -> Result, String> { +) -> Result<(), String> { let resp = reqwest::blocking::get(url) .map_err(|e| format!("下载失败: {}", e))?; + if !resp.status().is_success() { + return Err(format!("HTTP 错误: {}", resp.status())); + } + let total_size = resp.content_length().unwrap_or(0); let mut downloaded: u64 = 0; - let mut buffer = Vec::new(); let mut reader = resp; loop { + if buffer.is_cancelled() { + return Err("下载已取消".to_string()); + } + let mut chunk = [0u8; 8192]; let read_size = reader.read(&mut chunk) .map_err(|e| format!("读取失败: {}", e))?; if read_size == 0 { break; } - buffer.extend_from_slice(&chunk[..read_size]); + buffer.write_chunk(&chunk[..read_size]); downloaded += read_size as u64; - // 发送进度事件给前端(每 8192 字节发一次,不必太频繁) let progress = if total_size > 0 { (downloaded as f64 / total_size as f64) * 100.0 } else { - 0.0 // 未知大小时为 0 + 0.0 }; let _ = app_handle.emit("cache-progress", progress); } - // 下载完成,确保进度为 100 - let _ = app_handle.emit("cache-progress", 100f64); - Ok(buffer) + Ok(()) } +const INITIAL_BUFFER_SIZE: usize = 65536; + // ---------- 音频线程 ---------- fn audio_thread(rx: Receiver, current_url: Arc>>, app_handle: AppHandle) { let mut selected_device: Option = None; @@ -104,47 +244,148 @@ fn audio_thread(rx: Receiver, current_url: Arc>>, sink.set_volume(current_volume); } - let mut current_audio_data: Option> = None; // 缓存原始音频字节 + 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) => { - // 停止旧播放并重建干净输出 - if let Some(ref sink) = output.sink { - sink.stop(); + audio_active = false; + audio_paused = false; + manual_stop = false; + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); } + if let Some(ref sink) = output.sink { sink.stop(); } output = create_output(&selected_device); if let Some(ref sink) = output.sink { sink.set_volume(current_volume); - match download_audio_with_progress(&url, &app_handle) { - Ok(bytes) => { - current_audio_data = Some(bytes.clone()); - let play_res = play_bytes(&bytes, sink); - if let Err(e) = play_res { - eprintln!("[audio] 播放失败: {}", e); + let buffer = Arc::new(SharedBuffer::new()); + current_audio_buffer = Some(buffer.clone()); + + let buffer_clone = buffer.clone(); + let ah_clone = app_handle.clone(); + let url_clone = url.clone(); + thread::spawn(move || { + if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) { + if !buffer_clone.is_cancelled() { + eprintln!("[audio] 流式下载失败: {}", e); } } - Err(e) => eprintln!("[audio] 下载失败: {}", e), + buffer_clone.mark_done(); + }); + + loop { + let len = buffer.len(); + if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + if buffer.is_cancelled() || buffer.len() == 0 { + current_audio_buffer = None; + continue; + } + + let reader = StreamingReader::new(buffer.clone()); + match Decoder::new(reader) { + Ok(source) => { + sink.append(source); + sink.play(); + audio_active = true; + let _ = app_handle.emit("audio-started", ()); + } + Err(e) => { + eprintln!("[audio] 流式解码失败: {}, 等待完整下载后重试", e); + loop { + if buffer.is_done() || buffer.is_cancelled() { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if buffer.is_cancelled() || buffer.len() == 0 { + current_audio_buffer = None; + continue; + } + let buf = current_audio_buffer.as_ref().unwrap().clone(); + let reader2 = StreamingReader::new(buf); + match Decoder::new(reader2) { + Ok(source) => { + sink.append(source); + sink.play(); + audio_active = true; + let _ = app_handle.emit("audio-started", ()); + } + Err(e2) => { + eprintln!("[audio] 完整下载后解码也失败: {}", e2); + } + } + } + } + } + } + + AudioCmd::PlayLocal(path) => { + audio_active = false; + audio_paused = false; + manual_stop = false; + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } + if let Some(ref sink) = output.sink { sink.stop(); } + output = create_output(&selected_device); + if let Some(ref sink) = output.sink { + sink.set_volume(current_volume); + + match std::fs::read(&path) { + Ok(bytes) => { + let buffer = Arc::new(SharedBuffer::new()); + buffer.write_chunk(&bytes); + buffer.mark_done(); + current_audio_buffer = Some(buffer.clone()); + + let reader = StreamingReader::new(buffer); + match Decoder::new(reader) { + Ok(source) => { + sink.append(source); + sink.play(); + audio_active = true; + let _ = app_handle.emit("audio-started", ()); + } + Err(e) => eprintln!("[audio] 本地播放失败: {}", e), + } + } + Err(e) => eprintln!("[audio] 读取本地文件失败: {}", e), } } } AudioCmd::Pause => { + audio_paused = true; if let Some(ref sink) = output.sink { sink.pause(); } } AudioCmd::Resume => { + audio_paused = false; if let Some(ref sink) = output.sink { sink.play(); } } AudioCmd::Stop => { + audio_active = false; + audio_paused = false; + manual_stop = true; + if let Some(ref buf) = current_audio_buffer { + buf.cancel(); + } if let Some(ref sink) = output.sink { sink.stop(); } } AudioCmd::Seek(time) => { if let Some(ref sink) = output.sink { - // 优先尝试高效的 sink.try_seek(毫秒级) let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { sink.try_seek(Duration::from_secs_f64(time)) })); @@ -153,19 +394,34 @@ fn audio_thread(rx: Receiver, current_url: Arc>>, Ok(Ok(_)) => { /* 成功 */ } Ok(Err(e)) => { eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e); - // 回退方案:重新解码并从目标时间开始 - if let Some(ref bytes) = current_audio_data { + if let Some(ref buffer) = current_audio_buffer { sink.stop(); sink.clear(); - let _ = play_bytes_with_seek(bytes, sink, time); + let reader = StreamingReader::new(buffer.clone()); + match Decoder::new(reader) { + Ok(source) => { + let source = source.skip_duration(Duration::from_secs_f64(time)); + sink.append(source); + sink.play(); + } + Err(e) => eprintln!("[audio] seek 解码失败: {}", e), + } } } Err(_) => { eprintln!("[audio] try_seek 崩溃,回退重建解码"); - if let Some(ref bytes) = current_audio_data { + if let Some(ref buffer) = current_audio_buffer { sink.stop(); sink.clear(); - let _ = play_bytes_with_seek(bytes, sink, time); + let reader = StreamingReader::new(buffer.clone()); + match Decoder::new(reader) { + Ok(source) => { + let source = source.skip_duration(Duration::from_secs_f64(time)); + sink.append(source); + sink.play(); + } + Err(e) => eprintln!("[audio] seek 解码失败: {}", e), + } } } } @@ -184,10 +440,16 @@ fn audio_thread(rx: Receiver, current_url: Arc>>, output = create_output(&selected_device); if let Some(ref sink) = output.sink { sink.set_volume(current_volume); - // 如果正在播放,恢复播放 if current_url.lock().unwrap().is_some() { - if let Some(ref bytes) = current_audio_data { - let _ = play_bytes(bytes, sink); + if let Some(ref buffer) = current_audio_buffer { + let reader = StreamingReader::new(buffer.clone()); + match Decoder::new(reader) { + Ok(source) => { + sink.append(source); + sink.play(); + } + Err(e) => eprintln!("[audio] 设备切换解码失败: {}", e), + } } } } @@ -199,7 +461,16 @@ fn audio_thread(rx: Receiver, current_url: Arc>>, } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - // 跟随系统默认设备变化 + if audio_active && !audio_paused { + if let Some(ref sink) = output.sink { + if sink.empty() { + audio_active = false; + if !manual_stop { + let _ = app_handle.emit("audio-ended", ()); + } + } + } + } if selected_device.is_none() { let current_default = get_system_default_device_name(); if current_default != last_default_name { @@ -208,8 +479,12 @@ fn audio_thread(rx: Receiver, current_url: Arc>>, output = create_output(&selected_device); if let Some(ref sink) = output.sink { sink.set_volume(current_volume); - if let Some(ref bytes) = current_audio_data { - let _ = play_bytes(bytes, sink); + if let Some(ref buffer) = current_audio_buffer { + let reader = StreamingReader::new(buffer.clone()); + let _ = Decoder::new(reader).map(|source| { + sink.append(source); + sink.play(); + }); } } } @@ -220,28 +495,7 @@ fn audio_thread(rx: Receiver, current_url: Arc>>, } } -// ---------- 播放辅助函数 ---------- - -/// 直接播放字节数据 -fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> { - let cursor = Cursor::new(bytes.to_vec()); - let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?; - sink.append(source); - sink.play(); - Ok(()) -} - -/// 播放字节数据并跳过指定秒数(用于 seek 回退) -fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> { - let cursor = Cursor::new(bytes.to_vec()); - let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?; - let source = source.skip_duration(Duration::from_secs_f64(seek_secs)); - sink.append(source); - sink.play(); - Ok(()) -} - -// ---------- 其余函数保持不变(获取设备、创建输出等) ---------- +// ---------- 其余函数保持不变 ---------- fn get_system_default_device_name() -> Option { rodio::cpal::default_host() @@ -340,6 +594,13 @@ pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String> Ok(()) } +#[tauri::command] +pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> { + let ctrl = state.0.lock().map_err(|e| e.to_string())?; + ctrl.play_local(&path); + Ok(()) +} + #[tauri::command] pub fn pause_audio(state: State<'_, AppAudio>) { if let Ok(ctrl) = state.0.lock() { ctrl.pause(); } @@ -379,4 +640,4 @@ pub fn set_volume(state: State<'_, AppAudio>, vol: f32) { if let Ok(ctrl) = state.0.lock() { ctrl.set_volume(vol); } -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2553f5..3ad6a93 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,6 +56,7 @@ pub fn run() { "show" => { window.show().unwrap(); window.set_focus().unwrap(); + let _ = app.emit("window-shown", ()); } "play_pause" => { let _ = app.emit("tray-play-pause", ()); @@ -86,6 +87,7 @@ pub fn run() { let window = app.get_webview_window("main").unwrap(); window.show().unwrap(); window.set_focus().unwrap(); + let _ = app.emit("window-shown", ()); } }) .build(app)?; @@ -93,6 +95,7 @@ pub fn run() { // 点击关闭按钮时隐藏到托盘 let window = app.get_webview_window("main").unwrap(); let window_clone = window.clone(); + let app_handle = app.handle().clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api: close_api, .. } = event { if ALLOW_EXIT.load(Ordering::SeqCst) { @@ -100,6 +103,7 @@ pub fn run() { } close_api.prevent_close(); let _ = window_clone.hide(); + let _ = app_handle.emit("window-hidden", ()); } }); @@ -132,20 +136,31 @@ pub fn run() { api::exit_app, audio::play_audio, + audio::play_local_audio, audio::pause_audio, audio::resume_audio, audio::stop_audio, audio::get_output_devices, audio::set_output_device, audio::seek_audio, - audio::set_volume + audio::set_volume, + + api::download_song, + api::list_local_songs, + api::delete_local_song, + api::check_local_song, + api::get_default_download_path ]) + .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); let _ = window.unminimize(); + let _ = app.emit("window-shown", ()); } })) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7138b46..22c1c40 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.2.0", + "version": "0.3.0", "identifier": "com.atdunbg.Nekosonic", "build": { "beforeDevCommand": "npm run dev", @@ -34,6 +34,11 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "webviewInstallMode": { + "type": "downloadBootstrapper" + } + } } } diff --git a/src/App.vue b/src/App.vue index 44ce203..69bc7ae 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,7 +12,7 @@ -
+