feat: v0.3.0 - 流式播放、本地音乐、下载系统、漫游修复
### 新功能 - 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载 - 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」 - 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长) - 封面补全:本地音乐缺少封面时自动从网易云 API 获取 - 更新信息:接入 Gitea Releases API,查看最新版更新日志 ### 修复 - 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick) - 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片 - 修复 PlayerBar 无封面歌曲显示破损图片 - 修复下载路径修改后不生效(Rust serde camelCase 映射) - 修复本地音乐始终只显示默认路径歌曲 - 修复下载完成提示弹出 4 次 - 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步) ### 优化 - PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示 - audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended - player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步 - 切歌/停止时立即清除 tickInterval 防止重复触发 next()
This commit is contained in:
328
src-tauri/Cargo.lock
generated
328
src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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<ApiClient>,
|
||||
@ -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<String> }
|
||||
pub struct SongUrlQuery { pub id: u64, pub level: Option<String>, pub fm_mode: Option<bool> }
|
||||
|
||||
/// 获取歌曲播放地址
|
||||
/// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等)
|
||||
#[tauri::command]
|
||||
pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) -> Result<String, String> {
|
||||
let client = state.client.lock().await;
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
let 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<String>,
|
||||
pub filename: String,
|
||||
pub file_size: u64,
|
||||
pub path: String,
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadSongQuery {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub album: Option<String>,
|
||||
pub duration: Option<u64>,
|
||||
pub cover_url: Option<String>,
|
||||
pub level: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DownloadSongQuery,
|
||||
state: State<'_, ApiController>,
|
||||
) -> Result<String, String> {
|
||||
let level = query.level.as_deref().unwrap_or("standard");
|
||||
|
||||
let q = state.build_query()
|
||||
.param("id", &query.id.to_string())
|
||||
.param("level", level);
|
||||
let client = state.client.lock().await;
|
||||
let resp = client.song_url_v1(&q).await.map_err(|e| e.to_string())?;
|
||||
let data = &resp.body["data"][0];
|
||||
let url = data["url"].as_str().filter(|s| !s.is_empty());
|
||||
if url.is_none() {
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
return Err("暂无下载源,可能需要 VIP 权限".into());
|
||||
}
|
||||
let url = url.unwrap();
|
||||
let free_trial = data.get("freeTrialInfo");
|
||||
if free_trial.is_some() && !free_trial.unwrap().is_null() {
|
||||
return Err("VIP歌曲无法下载".into());
|
||||
}
|
||||
let ext = if url.contains(".flac") { "flac" } else { "mp3" };
|
||||
drop(client);
|
||||
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
let _ = fs::create_dir_all(&download_dir);
|
||||
|
||||
let safe_name = sanitize_filename(&query.name);
|
||||
let safe_artist = sanitize_filename(&query.artist);
|
||||
let filename = format!("{} - {}.{}", safe_artist, safe_name, ext);
|
||||
let filepath = download_dir.join(&filename);
|
||||
|
||||
if filepath.exists() {
|
||||
return Err("文件已存在".into());
|
||||
}
|
||||
|
||||
let resp = reqwest::get(url).await.map_err(|e| format!("下载失败: {}", e))?;
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
let temp_path = filepath.with_extension(format!("{}.tmp", ext));
|
||||
let mut file = fs::File::create(&temp_path).map_err(|e| format!("创建文件失败: {}", e))?;
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
let mut chunk_count: u64 = 0;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("读取失败: {}", e))?;
|
||||
file.write_all(&chunk).map_err(|e| format!("写入失败: {}", e))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
chunk_count += 1;
|
||||
if chunk_count % 8 == 0 || downloaded == total_size {
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let _ = app_handle.emit("download-progress", json!({
|
||||
"id": query.id,
|
||||
"progress": progress,
|
||||
"name": query.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
drop(file);
|
||||
|
||||
fs::rename(&temp_path, &filepath).map_err(|e| format!("重命名失败: {}", e))?;
|
||||
|
||||
let meta = json!({
|
||||
"id": query.id,
|
||||
"name": query.name,
|
||||
"artist": query.artist,
|
||||
"album": query.album,
|
||||
"duration": query.duration,
|
||||
"coverUrl": query.cover_url,
|
||||
"filename": filename,
|
||||
});
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
let mut meta_file = fs::File::create(&meta_path).map_err(|e| format!("创建元数据失败: {}", e))?;
|
||||
meta_file.write_all(serde_json::to_string_pretty(&meta).unwrap().as_bytes())
|
||||
.map_err(|e| format!("写入元数据失败: {}", e))?;
|
||||
|
||||
let _ = app_handle.emit("download-progress", json!({
|
||||
"id": query.id,
|
||||
"progress": 100.0,
|
||||
"name": query.name,
|
||||
}));
|
||||
|
||||
Ok(filename)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_local_songs(app_handle: tauri::AppHandle, download_path: Option<String>) -> Result<Vec<LocalSongInfo>, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
if !download_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let audio_exts = ["mp3", "flac", "wav", "ogg", "aac", "m4a", "wma", "opus"];
|
||||
|
||||
let mut meta_map: std::collections::HashMap<String, serde_json::Value> = std::collections::HashMap::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "json") {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(filename) = meta["filename"].as_str() {
|
||||
meta_map.insert(filename.to_string(), meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut songs: Vec<LocalSongInfo> = Vec::new();
|
||||
let entries = fs::read_dir(&download_dir).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !audio_exts.contains(&ext.to_lowercase().as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let file_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let (title, artist, album, duration_ms, cover_b64) = read_audio_metadata(&path);
|
||||
|
||||
if let Some(meta) = meta_map.get(&filename) {
|
||||
let meta_title = meta["name"].as_str().unwrap_or("");
|
||||
let meta_artist = meta["artist"].as_str().unwrap_or("");
|
||||
let meta_album = meta["album"].as_str().unwrap_or("");
|
||||
let meta_duration = meta["duration"].as_u64().unwrap_or(0);
|
||||
let meta_cover_url = meta["coverUrl"].as_str().unwrap_or("");
|
||||
|
||||
let final_title = if title.is_empty() { meta_title.to_string() } else { title };
|
||||
let final_artist = if artist.is_empty() { meta_artist.to_string() } else { artist };
|
||||
let final_album = if album.is_empty() { meta_album.to_string() } else { album };
|
||||
let final_duration = if duration_ms == 0 { meta_duration } else { duration_ms };
|
||||
let final_cover = cover_b64.or_else(|| {
|
||||
if meta_cover_url.is_empty() { None } else { Some(meta_cover_url.to_string()) }
|
||||
});
|
||||
|
||||
songs.push(LocalSongInfo {
|
||||
id: meta["id"].as_u64().unwrap_or(0),
|
||||
name: final_title,
|
||||
artist: final_artist,
|
||||
album: final_album,
|
||||
duration: final_duration,
|
||||
cover: final_cover,
|
||||
filename,
|
||||
file_size,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
local: true,
|
||||
});
|
||||
} else {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||
let (parsed_artist, parsed_name) = parse_filename(&stem);
|
||||
let final_title = if title.is_empty() { parsed_name } else { title };
|
||||
let final_artist = if artist.is_empty() { parsed_artist } else { artist };
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
filename.hash(&mut hasher);
|
||||
let hash_id = hasher.finish();
|
||||
|
||||
songs.push(LocalSongInfo {
|
||||
id: hash_id,
|
||||
name: final_title,
|
||||
artist: final_artist,
|
||||
album,
|
||||
duration: duration_ms,
|
||||
cover: cover_b64,
|
||||
filename,
|
||||
file_size,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
local: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
fn read_audio_metadata(path: &PathBuf) -> (String, String, String, u64, Option<String>) {
|
||||
match lofty::read_from_path(path) {
|
||||
Ok(tagged_file) => {
|
||||
let properties = tagged_file.properties();
|
||||
let duration_ms = properties.duration().as_millis() as u64;
|
||||
|
||||
let tag = tagged_file.primary_tag();
|
||||
let (title, artist, album) = if let Some(t) = tag {
|
||||
let title = t.title().map(|s| s.to_string()).unwrap_or_default();
|
||||
let artist = t.artist().map(|s| s.to_string()).unwrap_or_default();
|
||||
let album = t.album().map(|s| s.to_string()).unwrap_or_default();
|
||||
(title, artist, album)
|
||||
} else {
|
||||
(String::new(), String::new(), String::new())
|
||||
};
|
||||
|
||||
let cover_b64 = if let Some(t) = tag {
|
||||
if let Some(pic) = t.pictures().first() {
|
||||
let data = pic.data();
|
||||
let mime = pic.mime_type().map(|m| m.to_string()).unwrap_or_else(|| "image/jpeg".to_string());
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
|
||||
Some(format!("data:{};base64,{}", mime, b64))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(title, artist, album, duration_ms, cover_b64)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[api] 读取音频元数据失败 {}: {}", path.display(), e);
|
||||
(String::new(), String::new(), String::new(), 0, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_filename(stem: &str) -> (String, String) {
|
||||
if let Some(pos) = stem.find(" - ") {
|
||||
let artist = &stem[..pos];
|
||||
let name = &stem[pos + 3..];
|
||||
(artist.trim().to_string(), name.trim().to_string())
|
||||
} else if let Some(pos) = stem.find('-') {
|
||||
let artist = &stem[..pos];
|
||||
let name = &stem[pos + 1..];
|
||||
(artist.trim().to_string(), name.trim().to_string())
|
||||
} else {
|
||||
("".to_string(), stem.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteLocalSongQuery {
|
||||
pub id: u64,
|
||||
pub filename: String,
|
||||
pub download_path: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_local_song(
|
||||
app_handle: tauri::AppHandle,
|
||||
query: DeleteLocalSongQuery,
|
||||
) -> Result<(), String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, query.download_path.as_deref());
|
||||
let file_path = download_dir.join(&query.filename);
|
||||
let meta_path = download_dir.join(format!("{}.json", query.id));
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(&file_path).map_err(|e| format!("删除文件失败: {}", e))?;
|
||||
}
|
||||
if meta_path.exists() {
|
||||
fs::remove_file(&meta_path).map_err(|e| format!("删除元数据失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_local_song(app_handle: tauri::AppHandle, id: u64, download_path: Option<String>) -> Result<bool, String> {
|
||||
let download_dir = resolve_download_dir(&app_handle, download_path.as_deref());
|
||||
let meta_path = download_dir.join(format!("{}.json", id));
|
||||
Ok(meta_path.exists())
|
||||
}
|
||||
|
||||
fn resolve_download_dir(app_handle: &tauri::AppHandle, custom_path: Option<&str>) -> PathBuf {
|
||||
if let Some(path) = custom_path {
|
||||
if !path.is_empty() {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
get_default_download_dir(app_handle)
|
||||
}
|
||||
|
||||
fn get_default_download_dir(app_handle: &tauri::AppHandle) -> PathBuf {
|
||||
if let Ok(dir) = app_handle.path().app_data_dir() {
|
||||
let download_dir = dir.join("downloads");
|
||||
return download_dir;
|
||||
}
|
||||
let music_dir = dirs::audio_dir().unwrap_or_else(|| {
|
||||
std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
});
|
||||
music_dir.join("Nekosonic")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_download_path(app_handle: tauri::AppHandle) -> String {
|
||||
get_default_download_dir(&app_handle).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c == '/' || c == '\\' || c == ':' || c == '*' || c == '?'
|
||||
|| c == '"' || c == '<' || c == '>' || c == '|'
|
||||
{
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use rodio::{Decoder, OutputStream, Sink, Source};
|
||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||
use std::io::Cursor;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::AppHandle;
|
||||
@ -11,6 +11,7 @@ use tauri::Emitter;
|
||||
// ---------- 命令 ----------
|
||||
enum AudioCmd {
|
||||
Play(String),
|
||||
PlayLocal(String),
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
@ -26,21 +27,25 @@ pub struct AudioController {
|
||||
|
||||
impl AudioController {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let current_url = Arc::new(Mutex::new(None));
|
||||
let url_clone = current_url.clone();
|
||||
let ah_clone = app_handle.clone(); // 克隆一个用于闭包
|
||||
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
|
||||
AudioController {
|
||||
tx,
|
||||
current_url,
|
||||
let (tx, rx) = channel();
|
||||
let current_url = Arc::new(Mutex::new(None));
|
||||
let url_clone = current_url.clone();
|
||||
let ah_clone = app_handle.clone();
|
||||
thread::spawn(move || audio_thread(rx, url_clone, ah_clone));
|
||||
AudioController {
|
||||
tx,
|
||||
current_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_url(&self, url: &str) {
|
||||
*self.current_url.lock().unwrap() = Some(url.to_string());
|
||||
let _ = self.tx.send(AudioCmd::Play(url.to_string()));
|
||||
}
|
||||
pub fn play_local(&self, path: &str) {
|
||||
*self.current_url.lock().unwrap() = Some(path.to_string());
|
||||
let _ = self.tx.send(AudioCmd::PlayLocal(path.to_string()));
|
||||
}
|
||||
pub fn pause(&self) { let _ = self.tx.send(AudioCmd::Pause); }
|
||||
pub fn resume(&self) { let _ = self.tx.send(AudioCmd::Resume); }
|
||||
pub fn stop(&self) { let _ = self.tx.send(AudioCmd::Stop); }
|
||||
@ -55,44 +60,179 @@ impl AudioController {
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
// ---------- 流式缓冲区 ----------
|
||||
|
||||
fn download_audio_with_progress(
|
||||
struct BufferState {
|
||||
bytes: Vec<u8>,
|
||||
done: bool,
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
struct SharedBuffer {
|
||||
state: Mutex<BufferState>,
|
||||
available: Condvar,
|
||||
}
|
||||
|
||||
impl SharedBuffer {
|
||||
fn new() -> Self {
|
||||
SharedBuffer {
|
||||
state: Mutex::new(BufferState {
|
||||
bytes: Vec::new(),
|
||||
done: false,
|
||||
cancelled: false,
|
||||
}),
|
||||
available: Condvar::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_chunk(&self, chunk: &[u8]) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.bytes.extend_from_slice(chunk);
|
||||
self.available.notify_all();
|
||||
}
|
||||
|
||||
fn mark_done(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.done = true;
|
||||
self.available.notify_all();
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.cancelled = true;
|
||||
self.available.notify_all();
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.state.lock().unwrap().bytes.len()
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.state.lock().unwrap().done
|
||||
}
|
||||
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.state.lock().unwrap().cancelled
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingReader {
|
||||
buffer: Arc<SharedBuffer>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl StreamingReader {
|
||||
fn new(buffer: Arc<SharedBuffer>) -> Self {
|
||||
StreamingReader { buffer, pos: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for StreamingReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let mut state = self.buffer.state.lock().unwrap();
|
||||
loop {
|
||||
let available = state.bytes.len().saturating_sub(self.pos);
|
||||
if available > 0 {
|
||||
let to_read = std::cmp::min(buf.len(), available);
|
||||
buf[..to_read].copy_from_slice(&state.bytes[self.pos..self.pos + to_read]);
|
||||
self.pos += to_read;
|
||||
return Ok(to_read);
|
||||
}
|
||||
if state.done {
|
||||
return Ok(0);
|
||||
}
|
||||
if state.cancelled {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
|
||||
}
|
||||
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
|
||||
state = result.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for StreamingReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
let new_pos = match pos {
|
||||
SeekFrom::Start(offset) => offset as i64,
|
||||
SeekFrom::Current(offset) => self.pos as i64 + offset,
|
||||
SeekFrom::End(offset) => {
|
||||
let mut state = self.buffer.state.lock().unwrap();
|
||||
loop {
|
||||
if state.done {
|
||||
break state.bytes.len() as i64 + offset;
|
||||
}
|
||||
if state.cancelled {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
|
||||
}
|
||||
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
|
||||
state = result.0;
|
||||
}
|
||||
}
|
||||
};
|
||||
if new_pos < 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek before start"));
|
||||
}
|
||||
let mut state = self.buffer.state.lock().unwrap();
|
||||
loop {
|
||||
if new_pos as usize <= state.bytes.len() {
|
||||
self.pos = new_pos as usize;
|
||||
return Ok(self.pos as u64);
|
||||
}
|
||||
if state.done {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek past end"));
|
||||
}
|
||||
if state.cancelled {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled"));
|
||||
}
|
||||
let result = self.buffer.available.wait_timeout(state, Duration::from_millis(500)).unwrap();
|
||||
state = result.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn download_audio_streaming(
|
||||
url: &str,
|
||||
buffer: &SharedBuffer,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
) -> Result<(), String> {
|
||||
let resp = reqwest::blocking::get(url)
|
||||
.map_err(|e| format!("下载失败: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("HTTP 错误: {}", resp.status()));
|
||||
}
|
||||
|
||||
let total_size = resp.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut buffer = Vec::new();
|
||||
let mut reader = resp;
|
||||
|
||||
loop {
|
||||
if buffer.is_cancelled() {
|
||||
return Err("下载已取消".to_string());
|
||||
}
|
||||
|
||||
let mut chunk = [0u8; 8192];
|
||||
let read_size = reader.read(&mut chunk)
|
||||
.map_err(|e| format!("读取失败: {}", e))?;
|
||||
if read_size == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read_size]);
|
||||
buffer.write_chunk(&chunk[..read_size]);
|
||||
downloaded += read_size as u64;
|
||||
|
||||
// 发送进度事件给前端(每 8192 字节发一次,不必太频繁)
|
||||
let progress = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0 // 未知大小时为 0
|
||||
0.0
|
||||
};
|
||||
let _ = app_handle.emit("cache-progress", progress);
|
||||
}
|
||||
|
||||
// 下载完成,确保进度为 100
|
||||
let _ = app_handle.emit("cache-progress", 100f64);
|
||||
Ok(buffer)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const INITIAL_BUFFER_SIZE: usize = 65536;
|
||||
|
||||
// ---------- 音频线程 ----------
|
||||
fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>, app_handle: AppHandle) {
|
||||
let mut selected_device: Option<String> = None;
|
||||
@ -104,47 +244,148 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
sink.set_volume(current_volume);
|
||||
}
|
||||
|
||||
let mut current_audio_data: Option<Vec<u8>> = None; // 缓存原始音频字节
|
||||
let mut current_audio_buffer: Option<Arc<SharedBuffer>> = None;
|
||||
let mut audio_active = false;
|
||||
let mut audio_paused = false;
|
||||
let mut manual_stop = false;
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(200)) {
|
||||
Ok(cmd) => {
|
||||
match cmd {
|
||||
AudioCmd::Play(url) => {
|
||||
// 停止旧播放并重建干净输出
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.stop();
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
manual_stop = false;
|
||||
if let Some(ref buf) = current_audio_buffer {
|
||||
buf.cancel();
|
||||
}
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
|
||||
match download_audio_with_progress(&url, &app_handle) {
|
||||
Ok(bytes) => {
|
||||
current_audio_data = Some(bytes.clone());
|
||||
let play_res = play_bytes(&bytes, sink);
|
||||
if let Err(e) = play_res {
|
||||
eprintln!("[audio] 播放失败: {}", e);
|
||||
let buffer = Arc::new(SharedBuffer::new());
|
||||
current_audio_buffer = Some(buffer.clone());
|
||||
|
||||
let buffer_clone = buffer.clone();
|
||||
let ah_clone = app_handle.clone();
|
||||
let url_clone = url.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(e) = download_audio_streaming(&url_clone, &buffer_clone, &ah_clone) {
|
||||
if !buffer_clone.is_cancelled() {
|
||||
eprintln!("[audio] 流式下载失败: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 下载失败: {}", e),
|
||||
buffer_clone.mark_done();
|
||||
});
|
||||
|
||||
loop {
|
||||
let len = buffer.len();
|
||||
if len >= INITIAL_BUFFER_SIZE || buffer.is_done() || buffer.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
if buffer.is_cancelled() || buffer.len() == 0 {
|
||||
current_audio_buffer = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
audio_active = true;
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[audio] 流式解码失败: {}, 等待完整下载后重试", e);
|
||||
loop {
|
||||
if buffer.is_done() || buffer.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if buffer.is_cancelled() || buffer.len() == 0 {
|
||||
current_audio_buffer = None;
|
||||
continue;
|
||||
}
|
||||
let buf = current_audio_buffer.as_ref().unwrap().clone();
|
||||
let reader2 = StreamingReader::new(buf);
|
||||
match Decoder::new(reader2) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
audio_active = true;
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e2) => {
|
||||
eprintln!("[audio] 完整下载后解码也失败: {}", e2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::PlayLocal(path) => {
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
manual_stop = false;
|
||||
if let Some(ref buf) = current_audio_buffer {
|
||||
buf.cancel();
|
||||
}
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let buffer = Arc::new(SharedBuffer::new());
|
||||
buffer.write_chunk(&bytes);
|
||||
buffer.mark_done();
|
||||
current_audio_buffer = Some(buffer.clone());
|
||||
|
||||
let reader = StreamingReader::new(buffer);
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
audio_active = true;
|
||||
let _ = app_handle.emit("audio-started", ());
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 本地播放失败: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 读取本地文件失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioCmd::Pause => {
|
||||
audio_paused = true;
|
||||
if let Some(ref sink) = output.sink { sink.pause(); }
|
||||
}
|
||||
AudioCmd::Resume => {
|
||||
audio_paused = false;
|
||||
if let Some(ref sink) = output.sink { sink.play(); }
|
||||
}
|
||||
AudioCmd::Stop => {
|
||||
audio_active = false;
|
||||
audio_paused = false;
|
||||
manual_stop = true;
|
||||
if let Some(ref buf) = current_audio_buffer {
|
||||
buf.cancel();
|
||||
}
|
||||
if let Some(ref sink) = output.sink { sink.stop(); }
|
||||
}
|
||||
|
||||
AudioCmd::Seek(time) => {
|
||||
if let Some(ref sink) = output.sink {
|
||||
// 优先尝试高效的 sink.try_seek(毫秒级)
|
||||
let seek_res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
sink.try_seek(Duration::from_secs_f64(time))
|
||||
}));
|
||||
@ -153,19 +394,34 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
Ok(Ok(_)) => { /* 成功 */ }
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("[audio] try_seek 失败: {:?}, 回退重建解码", e);
|
||||
// 回退方案:重新解码并从目标时间开始
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
let source = source.skip_duration(Duration::from_secs_f64(time));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
}
|
||||
Err(e) => eprintln!("[audio] seek 解码失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("[audio] try_seek 崩溃,回退重建解码");
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
sink.stop();
|
||||
sink.clear();
|
||||
let _ = play_bytes_with_seek(bytes, sink, time);
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
let source = source.skip_duration(Duration::from_secs_f64(time));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
}
|
||||
Err(e) => eprintln!("[audio] seek 解码失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -184,10 +440,16 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
// 如果正在播放,恢复播放
|
||||
if current_url.lock().unwrap().is_some() {
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
match Decoder::new(reader) {
|
||||
Ok(source) => {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
}
|
||||
Err(e) => eprintln!("[audio] 设备切换解码失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -199,7 +461,16 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
// 跟随系统默认设备变化
|
||||
if audio_active && !audio_paused {
|
||||
if let Some(ref sink) = output.sink {
|
||||
if sink.empty() {
|
||||
audio_active = false;
|
||||
if !manual_stop {
|
||||
let _ = app_handle.emit("audio-ended", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected_device.is_none() {
|
||||
let current_default = get_system_default_device_name();
|
||||
if current_default != last_default_name {
|
||||
@ -208,8 +479,12 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
output = create_output(&selected_device);
|
||||
if let Some(ref sink) = output.sink {
|
||||
sink.set_volume(current_volume);
|
||||
if let Some(ref bytes) = current_audio_data {
|
||||
let _ = play_bytes(bytes, sink);
|
||||
if let Some(ref buffer) = current_audio_buffer {
|
||||
let reader = StreamingReader::new(buffer.clone());
|
||||
let _ = Decoder::new(reader).map(|source| {
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -220,28 +495,7 @@ fn audio_thread(rx: Receiver<AudioCmd>, current_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 播放辅助函数 ----------
|
||||
|
||||
/// 直接播放字节数据
|
||||
fn play_bytes(bytes: &[u8], sink: &Sink) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 播放字节数据并跳过指定秒数(用于 seek 回退)
|
||||
fn play_bytes_with_seek(bytes: &[u8], sink: &Sink, seek_secs: f64) -> Result<(), String> {
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("解码失败: {}", e))?;
|
||||
let source = source.skip_duration(Duration::from_secs_f64(seek_secs));
|
||||
sink.append(source);
|
||||
sink.play();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- 其余函数保持不变(获取设备、创建输出等) ----------
|
||||
// ---------- 其余函数保持不变 ----------
|
||||
|
||||
fn get_system_default_device_name() -> Option<String> {
|
||||
rodio::cpal::default_host()
|
||||
@ -340,6 +594,13 @@ pub fn play_audio(state: State<'_, AppAudio>, url: String) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn play_local_audio(state: State<'_, AppAudio>, path: String) -> Result<(), String> {
|
||||
let ctrl = state.0.lock().map_err(|e| e.to_string())?;
|
||||
ctrl.play_local(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pause_audio(state: State<'_, AppAudio>) {
|
||||
if let Ok(ctrl) = state.0.lock() { ctrl.pause(); }
|
||||
@ -379,4 +640,4 @@ pub fn set_volume(state: State<'_, AppAudio>, vol: f32) {
|
||||
if let Ok(ctrl) = state.0.lock() {
|
||||
ctrl.set_volume(vol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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!())
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user